Compare commits

...

138 Commits

Author SHA1 Message Date
jxxghp
b04181fed9 更新 version.py 2024-12-19 20:24:11 +08:00
jxxghp
eee843bafd Merge pull request #3567 from InfinityPacer/feature/cache 2024-12-19 20:21:00 +08:00
InfinityPacer
134fd0761d refactor(cache): split douban cache into recommend and search 2024-12-19 20:00:29 +08:00
InfinityPacer
669481af06 feat(cache): unify bangumi cache strategy 2024-12-19 19:42:17 +08:00
jxxghp
b5640b3179 Merge pull request #3564 from InfinityPacer/feature/subscribe 2024-12-19 16:17:14 +08:00
InfinityPacer
9abb305dbb fix(subscribe): ensure best version is empty set 2024-12-19 15:41:51 +08:00
InfinityPacer
0fd4791479 fix(event): align field names with SubscribeComplete 2024-12-19 10:58:11 +08:00
jxxghp
ce2ecdf44c Merge pull request #3562 from InfinityPacer/feature/subscribe 2024-12-19 07:02:26 +08:00
InfinityPacer
949c0d3b76 feat(subscribe): optimize best version to support multiple states 2024-12-19 00:51:53 +08:00
jxxghp
316915842a Merge pull request #3559 from InfinityPacer/feature/site 2024-12-18 19:24:34 +08:00
jxxghp
1dd7dc36c3 Merge pull request #3557 from InfinityPacer/feature/subscribe 2024-12-18 19:24:00 +08:00
InfinityPacer
fca763b814 fix(site): avoid err_msg cannot be updated when it's None 2024-12-18 16:39:14 +08:00
InfinityPacer
9311125c72 fix(subscribe): avoid reinitializing the dictionary 2024-12-18 15:49:21 +08:00
InfinityPacer
3f1d4933c1 Merge pull request #3553 from InfinityPacer/feature/subscribe
fix(dependencies): pin python-115 version
2024-12-18 12:47:51 +08:00
InfinityPacer
7fb23b5069 fix(dependencies): pin python-115 version 2024-12-18 12:46:28 +08:00
DDSRem
d74ad343f1 Merge pull request #3551 from InfinityPacer/feature/subscribe
Revert "chore(deps): update dependency python-115 to v0.0.9.8.8.3"
2024-12-18 10:42:17 +08:00
InfinityPacer
c0a8351e58 Revert "chore(deps): update dependency python-115 to v0.0.9.8.8.3"
This reverts commit d182a7079d.
2024-12-18 10:39:37 +08:00
jxxghp
8e309e8658 更新 version.py 2024-12-17 22:19:32 +08:00
jxxghp
3400a9f87a fix #3548 2024-12-17 12:44:37 +08:00
jxxghp
c6830059b2 Merge pull request #3548 from 0honus0/v2 2024-12-17 11:54:36 +08:00
honus
7e4a18b365 fix rclone __get_fileitem err 2024-12-17 00:18:52 +08:00
honus
9ecc8c14d8 fix rclone bug 2024-12-16 23:20:49 +08:00
jxxghp
91ba71ad23 Merge pull request #3546 from InfinityPacer/feature/subscribe 2024-12-16 19:47:30 +08:00
jxxghp
5ae8914060 Merge pull request #3545 from xianghuawe/v2 2024-12-16 19:46:18 +08:00
InfinityPacer
77c8f1244f Merge branch 'v2' of https://github.com/jxxghp/MoviePilot into feature/subscribe 2024-12-16 19:09:14 +08:00
InfinityPacer
5d5c8a0af7 feat(event): add SubscribeDeleted event 2024-12-16 19:09:00 +08:00
coder_wen
dcaf3e6678 fix: change alist.py upload api to put, fix big file upload over memory limit #3265 2024-12-16 15:14:16 +08:00
jxxghp
c0170a173c Merge pull request #3542 from DDS-Derek/dev 2024-12-16 12:56:19 +08:00
DDSRem
d182a7079d chore(deps): update dependency python-115 to v0.0.9.8.8.3 2024-12-16 12:28:50 +08:00
jxxghp
b5cc5653b2 Merge pull request #3536 from InfinityPacer/feature/subscribe 2024-12-15 07:56:06 +08:00
jxxghp
bdbd908b3a Merge pull request #3535 from InfinityPacer/feature/event 2024-12-15 07:55:15 +08:00
InfinityPacer
11fedb1ffc fix(download): optimize performance by checking binary content 2024-12-15 01:27:30 +08:00
InfinityPacer
7de82f6c0d fix(event): remove unnecessary code 2024-12-15 00:17:53 +08:00
jxxghp
782829c992 Merge pull request #3531 from InfinityPacer/feature/subscribe 2024-12-13 20:18:58 +08:00
InfinityPacer
6ab76453d4 feat(events): update episodes field to Download event 2024-12-13 20:05:40 +08:00
jxxghp
56767b92d7 Merge pull request #3524 from InfinityPacer/feature/subscribe 2024-12-12 17:29:17 +08:00
InfinityPacer
621df40c66 feat(event): add support for priority in event registration 2024-12-12 15:38:28 +08:00
jxxghp
ba7cb76640 Merge pull request #3519 from InfinityPacer/feature/subscribe 2024-12-11 22:27:24 +08:00
InfinityPacer
d353853472 feat(subscribe): add support for update movie downloaded note 2024-12-11 20:19:47 +08:00
InfinityPacer
1fcf5f4709 feat(subscribe): add state reset to 'R' on subscription reset 2024-12-11 20:01:10 +08:00
InfinityPacer
0ec4630461 fix(subscribe): avoid redundant updates for remaining episodes 2024-12-11 16:31:11 +08:00
InfinityPacer
fa45dea1aa fix(subscribe): prioritize update state when fininsh subscribe 2024-12-11 16:18:03 +08:00
InfinityPacer
2217583052 fix(subscribe): update missing episode logic and return status 2024-12-11 15:51:04 +08:00
InfinityPacer
f4dc7a133e fix(subscribe): update subscription state after download 2024-12-11 15:47:45 +08:00
jxxghp
26b1e64bad Merge pull request #3518 from InfinityPacer/feature/subscribe 2024-12-11 13:32:17 +08:00
InfinityPacer
a1d8af6521 fix(subscribe): update remove_site to set sites as an empty list 2024-12-11 12:39:13 +08:00
jxxghp
9fb3d093ff Merge pull request #3517 from wikrin/match_rule 2024-12-11 06:54:58 +08:00
jxxghp
8c9b37a12f Merge pull request #3516 from InfinityPacer/feature/subscribe 2024-12-11 06:53:42 +08:00
Attente
73e4596d1a feat(filter): add publish time filter for torrents
- 在 `TorrentInfo` 类中添加 `pub_minutes` 方法以计算自发布以来的`分钟`数
- 在 FilterModule 中实现发布时间过滤
- 支持发布时间的单值和范围比较
2024-12-10 23:36:54 +08:00
InfinityPacer
83798e6823 feat(event): add multiple IDs to source with json 2024-12-10 21:23:52 +08:00
InfinityPacer
6d9595b643 feat(event): add source tracking in download event 2024-12-10 18:50:50 +08:00
jxxghp
dc047d949d Merge pull request #3511 from wikrin/offset 2024-12-10 07:13:10 +08:00
Attente
a31b4bc0a1 refactor(app): improve episode offset calculation
- Remove unnecessary try-except block
2024-12-10 00:37:50 +08:00
Attente
94b8633803 手动整理中集数偏移可不使用集数定位 2024-12-10 00:32:01 +08:00
jxxghp
107e85033f Merge pull request #3507 from InfinityPacer/feature/subscribe 2024-12-09 19:38:48 +08:00
InfinityPacer
eea8060182 feat(plugin): add username support for post_message 2024-12-09 19:27:25 +08:00
jxxghp
83f7869de4 Merge pull request #3506 from thsrite/v2 2024-12-09 17:32:49 +08:00
thsrite
4f0eff8b88 fix site vip level ignores ratio warning 2024-12-09 16:43:05 +08:00
jxxghp
58b438c345 fix #3343 2024-12-08 08:51:58 +08:00
jxxghp
bc57bb1a78 更新 version.py 2024-12-07 07:41:14 +08:00
jxxghp
e08ab0dd33 Merge pull request #3341 from InfinityPacer/feature/subscribe 2024-12-07 07:39:28 +08:00
InfinityPacer
64bfa246ae fix: replace is None with is_(None) for proper SQLAlchemy filter 2024-12-07 01:09:03 +08:00
jxxghp
cde4db1a56 v2.1.2 2024-12-06 15:55:56 +08:00
jxxghp
29ae910953 fix build 2024-12-06 12:31:29 +08:00
jxxghp
314f90cc40 upgrade python-115 2024-12-06 12:30:13 +08:00
jxxghp
1c22e3d024 Merge pull request #3337 from InfinityPacer/feature/subscribe
feat(event): add ResourceDownload event for cancel download
2024-12-06 11:17:34 +08:00
InfinityPacer
233d62479f feat(event): add options to ResourceDownloadEventData 2024-12-06 10:47:56 +08:00
jxxghp
6974f2ebd7 Merge pull request #3335 from mackerel-12138/fix_scraper 2024-12-06 06:53:24 +08:00
InfinityPacer
c030166cf5 feat(event): send events for resource download based on source 2024-12-06 02:08:36 +08:00
InfinityPacer
4c511eaea6 chore(event): update ResourceDownloadEventData comment 2024-12-06 02:06:00 +08:00
InfinityPacer
6e443a1127 feat(event): add ResourceDownload event for cancel download 2024-12-06 01:55:44 +08:00
InfinityPacer
896e473c41 fix(event): filter and handle only enabled event handlers 2024-12-06 01:54:51 +08:00
zhanglijun
12f10ebedf fix: 音轨文件重命名整理 2024-12-06 00:40:38 +08:00
jxxghp
ba9f85747c Merge pull request #3330 from InfinityPacer/feature/subscribe 2024-12-05 17:10:47 +08:00
InfinityPacer
2954c02a7c feat(subscribe): add subscription status update API 2024-12-05 16:24:05 +08:00
InfinityPacer
312e602f12 feat(subscribe): add Pending and Suspended subscription states 2024-12-05 16:22:09 +08:00
InfinityPacer
ed37fcbb07 feat(subscribe): update get_by_state to handle multiple states 2024-12-05 16:20:14 +08:00
jxxghp
6acf8fbf00 Merge pull request #3324 from InfinityPacer/feature/subscribe 2024-12-05 06:54:45 +08:00
InfinityPacer
a1e178c805 feat(event): add ResourceSelection event for update resource contexts 2024-12-04 20:21:57 +08:00
jxxghp
922e2fc446 Merge pull request #3323 from Aqr-K/feat-module 2024-12-04 18:19:15 +08:00
jxxghp
db4c8cb3f2 Merge pull request #3322 from InfinityPacer/feature/subscribe 2024-12-04 18:18:32 +08:00
Aqr-K
1c578746fe fix(module): 补全 indexer 缺少 get_subtype 方法
- 补全 `indexer` 缺少 `get_subtype` 方法。
- 增加 `get_running_subtype_module` 方法,可结合 `types` 快速获取单个运行中的 `module` 。
2024-12-04 18:14:56 +08:00
InfinityPacer
68f88117b6 feat(events): add episodes field to DownloadAdded event for unpack 2024-12-04 16:11:35 +08:00
jxxghp
108c0a89f6 Merge pull request #3320 from InfinityPacer/feature/subscribe 2024-12-04 12:18:19 +08:00
InfinityPacer
92dacdf6a2 fix(subscribe): add RLock to prevent duplicate subscription downloads 2024-12-04 11:07:45 +08:00
InfinityPacer
6aa684d6a5 fix(subscribe): handle case when no subscriptions are found 2024-12-04 11:03:32 +08:00
InfinityPacer
efece8cc56 fix(subscribe): add check for None before updating subscription 2024-12-04 10:27:33 +08:00
jxxghp
383c8ca19a Merge pull request #3313 from Aqr-K/feat-module 2024-12-03 18:09:49 +08:00
jxxghp
0a73681280 Merge pull request #3315 from InfinityPacer/feature/scheduler 2024-12-03 18:09:23 +08:00
InfinityPacer
c1ecda280c fix #3312 2024-12-03 17:33:00 +08:00
Aqr-K
825fc35134 feat(modules): 增加子级 type 分类。
- 在 `types` 里,针对各个模块的类型进行子级分类。
- 为每个模块统一添加 `get_subtype` 方法,这样一来,能更精准快速地区分与调用子类的每个模块,又能获取 ModuleType 所规定的分类以及对应存在的子模块类型支持列表,从而有效解决当下调用时需繁琐遍历每个 module 以获取 get_name 或 _channel 的问题。
- 解决因消息渠道前端返回所保存的 type 与后端规定值不一致,而需要频繁调用 _channel 私有方法才能获取分类所可能产生的问题。
2024-12-03 14:57:19 +08:00
jxxghp
8f543ca602 Merge pull request #3309 from yxlimo/tmdbid-for-downloader 2024-12-03 06:55:36 +08:00
yxlimo
f0ecc1a497 fix: return last record when get downloadhistory by hash 2024-12-02 22:55:57 +08:00
jxxghp
71f170a1ad Merge pull request #3293 from wikrin/v2 2024-12-01 10:23:51 +08:00
Attente
3709b65b0e fix(api): correct variable reference in media scraping logic
- Change incorrect reference from media_info to mediainfo
2024-12-01 03:40:30 +08:00
jxxghp
9d6eb0f1e1 Merge pull request #3291 from mackerel-12138/fix_scraper 2024-11-30 16:06:04 +08:00
jxxghp
c93306147b Merge pull request #3290 from mackerel-12138/fix_poster 2024-11-30 16:05:11 +08:00
zhanglijun
5e8f924a2f fix: 修复指定tmdbid刮削时tmdbid丢失问题 2024-11-30 15:57:47 +08:00
zhanglijun
54988d6397 fix: 修复fanart季图片下载缺失/错误的问题 2024-11-30 13:51:30 +08:00
jxxghp
112761dc4c Merge pull request #3287 from InfinityPacer/feature/security 2024-11-30 07:15:52 +08:00
InfinityPacer
ef20508840 feat(auth): handle service instance retrieval with proper null check 2024-11-30 01:14:36 +08:00
InfinityPacer
589a1765ed feat(auth): support specifying service for authentication 2024-11-30 01:04:48 +08:00
jxxghp
2c666e24f3 Merge pull request #3283 from InfinityPacer/feature/subscribe 2024-11-29 21:12:25 +08:00
InfinityPacer
168e3c5533 fix(subscribe): move state update to finally to prevent duplicates 2024-11-29 18:56:19 +08:00
jxxghp
cda8b2573a Merge pull request #3282 from InfinityPacer/feature/subscribe 2024-11-29 16:47:56 +08:00
InfinityPacer
4cb4eb23b8 fix(subscribe): prevent fallback to search rules if not defined 2024-11-29 16:15:37 +08:00
jxxghp
f208b65570 更新 version.py 2024-11-29 08:59:55 +08:00
jxxghp
8a0a530036 Merge pull request #3279 from wikrin/v2 2024-11-29 07:36:34 +08:00
Attente
76643f13ed Update system.py 2024-11-29 07:33:02 +08:00
Attente
6992284a77 fix(api): 修复规则测试未获取到媒体信息导致的过滤失败问题 2024-11-29 07:25:08 +08:00
jxxghp
9a142799cd Merge pull request #3274 from InfinityPacer/feature/encoding 2024-11-28 17:29:22 +08:00
InfinityPacer
027d1567c3 feat(encoding): set PERFORMANCE_MODE to enabled by default 2024-11-28 17:07:14 +08:00
jxxghp
65af737dfd Merge pull request #3272 from wikrin/transfer 2024-11-28 07:23:25 +08:00
jxxghp
48aa0e3d0b Merge pull request #3271 from wikrin/v2 2024-11-28 07:22:16 +08:00
jxxghp
b4e31893ff Merge pull request #3268 from mackerel-12138/fix_scraper 2024-11-28 07:21:43 +08:00
Attente
4f1b95352a 改进手动整理逻辑 关联后端 jxxghp/MoviePilot-Frontend#255 2024-11-28 05:39:26 +08:00
Attente
ca664cb569 fix: 修复批量整理时媒体库目录匹配不正确的问题 2024-11-28 05:19:09 +08:00
zhanglijun
fe4ea73286 修复季nfo刮削错误, 优化季标题取值 2024-11-27 23:27:08 +08:00
jxxghp
9e9cca6de4 Merge pull request #3262 from InfinityPacer/feature/encoding 2024-11-27 16:25:46 +08:00
InfinityPacer
2e7e74c803 feat(encoding): update configuration to performance mode 2024-11-27 13:52:17 +08:00
InfinityPacer
916597047d Merge branch 'v2' of https://github.com/jxxghp/MoviePilot into feature/encoding 2024-11-27 12:52:01 +08:00
InfinityPacer
83fc474dbe feat(encoding): enhance encoding detection with confidence threshold 2024-11-27 12:33:57 +08:00
jxxghp
f67bf49e69 Merge pull request #3255 from InfinityPacer/feature/event 2024-11-27 06:59:54 +08:00
jxxghp
bf9043f526 Merge pull request #3254 from mackerel-12138/v2 2024-11-27 06:58:47 +08:00
InfinityPacer
a98de604a1 refactor(event): rename SmartRename to TransferRename 2024-11-27 00:50:34 +08:00
InfinityPacer
e160a745a7 fix(event): correct visualize_handlers 2024-11-27 00:49:37 +08:00
zhanglijun
7f2c6ef167 fix: 增加入参判断 2024-11-26 22:25:42 +08:00
jxxghp
2086651dbe Merge pull request #3235 from wikrin/fix 2024-11-26 22:17:32 +08:00
zhanglijun
132fde2308 修复季海报下载路径和第0季海报命名 2024-11-26 22:01:00 +08:00
jxxghp
4e27a1e623 fix #3247 2024-11-26 08:25:01 +08:00
Attente
a453831deb get_dir增加入参
- `include_unsorted`用于表示可否`包含`整理方式`为`不整理`的目录配置
2024-11-26 03:11:25 +08:00
jxxghp
1035ceb4ac Merge pull request #3245 from wikrin/v2 2024-11-25 23:04:15 +08:00
Attente
b7cb917347 fix(transfer): add library type and category folder support
- Add library_type_folder and library_category_folder parameters to the transfer function
- This enhances the transfer functionality by allowing sorting files into folders based on library type and category
2024-11-25 23:02:17 +08:00
jxxghp
680ad164dc Merge pull request #3236 from InfinityPacer/feature/scheduler 2024-11-25 17:54:05 +08:00
InfinityPacer
aed68253e9 feat(scheduler): expose internal methods for external invocation 2024-11-25 16:33:17 +08:00
InfinityPacer
b83c7a5656 feat(scheduler): support plugin method arguments via func_kwargs 2024-11-25 16:31:30 +08:00
InfinityPacer
491456b0a2 feat(scheduler): support plugin replacement for system services 2024-11-25 16:30:11 +08:00
Attente
84465a6536 不整理目录的下载路径可以被下载器获取
修改自动匹配源存储器类型入参
2024-11-25 13:51:04 +08:00
64 changed files with 1531 additions and 812 deletions

View File

@@ -5,10 +5,7 @@ on:
branches:
- v2
paths:
- '.github/workflows/build.yml'
- 'Dockerfile'
- 'version.py'
- 'requirements.in'
jobs:
Docker-build:

View File

@@ -51,7 +51,7 @@ def download(
torrent_info=torrentinfo
)
did = DownloadChain().download_single(context=context, username=current_user.name,
downloader=downloader, save_path=save_path)
downloader=downloader, save_path=save_path, source="Manual")
if not did:
return schemas.Response(success=False, message="任务添加失败")
return schemas.Response(success=True, data={
@@ -84,7 +84,7 @@ def add(
torrent_info=torrentinfo
)
did = DownloadChain().download_single(context=context, username=current_user.name,
downloader=downloader, save_path=save_path)
downloader=downloader, save_path=save_path, source="Manual")
if not did:
return schemas.Response(success=False, message="任务添加失败")
return schemas.Response(success=True, data={

View File

@@ -111,7 +111,7 @@ def scrape(fileitem: schemas.FileItem,
scrape_path = Path(fileitem.path)
meta = MetaInfoPath(scrape_path)
mediainfo = chain.recognize_by_meta(meta)
if not media_info:
if not mediainfo:
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
if storage == "local":
if not scrape_path.exists():

View File

@@ -8,6 +8,7 @@ from app import schemas
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo
from app.core.security import verify_token, verify_apitoken
from app.db import get_db
@@ -17,7 +18,7 @@ from app.db.models.user import User
from app.db.user_oper import get_current_active_user
from app.helper.subscribe import SubscribeHelper
from app.scheduler import Scheduler
from app.schemas.types import MediaType
from app.schemas.types import MediaType, EventType
router = APIRouter()
@@ -124,6 +125,27 @@ def update_subscribe(
return schemas.Response(success=True)
@router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response)
def update_subscribe_status(
subid: int,
state: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
更新订阅状态
"""
subscribe = Subscribe.get(db, subid)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
valid_states = ["R", "P", "S"]
if state not in valid_states:
return schemas.Response(success=False, message="无效的订阅状态")
subscribe.update(db, {
"state": state
})
return schemas.Response(success=True)
@router.get("/media/{mediaid}", summary="查询订阅", response_model=schemas.Subscribe)
def subscribe_mediaid(
mediaid: str,
@@ -186,8 +208,9 @@ def reset_subscribes(
subscribe = Subscribe.get(db, subid)
if subscribe:
subscribe.update(db, {
"note": "",
"lack_episode": subscribe.total_episode
"note": [],
"lack_episode": subscribe.total_episode,
"state": "R"
})
return schemas.Response(success=True)
return schemas.Response(success=False, message="订阅不存在")
@@ -252,17 +275,27 @@ def delete_subscribe_by_mediaid(
"""
根据TMDBID或豆瓣ID删除订阅 tmdb:/douban:
"""
delete_subscribes = []
if mediaid.startswith("tmdb:"):
tmdbid = mediaid[5:]
if not tmdbid or not str(tmdbid).isdigit():
return schemas.Response(success=False)
Subscribe().delete_by_tmdbid(db, int(tmdbid), season)
subscribes = Subscribe().get_by_tmdbid(db, int(tmdbid), season)
delete_subscribes.extend(subscribes)
elif mediaid.startswith("douban:"):
doubanid = mediaid[7:]
if not doubanid:
return schemas.Response(success=False)
Subscribe().delete_by_doubanid(db, doubanid)
subscribe = Subscribe().get_by_doubanid(db, doubanid)
if subscribe:
delete_subscribes.append(subscribe)
for subscribe in delete_subscribes:
Subscribe().delete(db, subscribe.id)
# 发送事件
eventmanager.send_event(EventType.SubscribeDeleted, {
"subscribe_id": subscribe.id,
"subscribe_info": subscribe.to_dict()
})
return schemas.Response(success=True)
@@ -485,9 +518,14 @@ def delete_subscribe(
subscribe = Subscribe.get(db, subscribe_id)
if subscribe:
subscribe.delete(db, subscribe_id)
# 统计订阅
SubscribeHelper().sub_done_async({
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
})
# 发送事件
eventmanager.send_event(EventType.SubscribeDeleted, {
"subscribe_id": subscribe_id,
"subscribe_info": subscribe.to_dict()
})
# 统计订阅
SubscribeHelper().sub_done_async({
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
})
return schemas.Response(success=True)

View File

@@ -16,6 +16,7 @@ from app import schemas
from app.chain.search import SearchChain
from app.chain.system import SystemChain
from app.core.config import global_vars, settings
from app.core.metainfo import MetaInfo
from app.core.module import ModuleManager
from app.core.security import verify_apitoken, verify_resource_token, verify_token
from app.db.models import User
@@ -385,9 +386,14 @@ def ruletest(title: str,
if not rulegroup:
return schemas.Response(success=False, message=f"过滤规则组 {rulegroup_name} 不存在!")
# 根据标题查询媒体信息
media_info = SearchChain().recognize_media(MetaInfo(title=title, subtitle=subtitle))
if not media_info:
return schemas.Response(success=False, message="未识别到媒体信息!")
# 过滤
result = SearchChain().filter_torrents(rule_groups=[rulegroup.name],
torrent_list=[torrent])
torrent_list=[torrent], mediainfo=media_info)
if not result:
return schemas.Response(success=False, message="不符合过滤规则!")
return schemas.Response(success=True, data={

View File

@@ -1,8 +1,7 @@
from pathlib import Path
from typing import Any, Optional
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app import schemas
@@ -14,32 +13,11 @@ from app.core.security import verify_token, verify_apitoken
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
from app.db.user_oper import get_current_active_superuser
from app.schemas import MediaType, FileItem
from app.schemas import MediaType, FileItem, ManualTransferItem
router = APIRouter()
class ManualTransferItem(BaseModel):
fileitem: FileItem = None,
logid: Optional[int] = None,
target_storage: Optional[str] = None,
target_path: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
type_name: Optional[str] = None,
season: Optional[int] = None,
transfer_type: Optional[str] = None,
episode_format: Optional[str] = None,
episode_detail: Optional[str] = None,
episode_part: Optional[str] = None,
episode_offset: Optional[str] = None,
min_filesize: Optional[int] = 0,
scrape: bool = False,
library_type_folder: bool = False,
library_category_folder: bool = False,
from_history: bool = False
@router.get("/name", summary="查询整理后的名称", response_model=schemas.Response)
def query_name(path: str, filetype: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:

View File

@@ -20,7 +20,8 @@ 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
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType
from app.schemas.event import ResourceSelectionEventData, ResourceDownloadEventData
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ChainEventType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
@@ -191,7 +192,7 @@ class DownloadChain(ChainBase):
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
self.post_message(Notification(
channel=channel,
source=source,
source=source if channel else None,
mtype=NotificationType.Manual,
title=f"{torrent.title} 种子下载失败!",
text=f"错误信息:{error_msg}\n站点:{torrent.site_name}",
@@ -203,7 +204,8 @@ class DownloadChain(ChainBase):
def download_single(self, context: Context, torrent_file: Path = None,
episodes: Set[int] = None,
channel: MessageChannel = None, source: str = None,
channel: MessageChannel = None,
source: str = None,
downloader: str = None,
save_path: str = None,
userid: Union[str, int] = None,
@@ -215,13 +217,38 @@ class DownloadChain(ChainBase):
:param torrent_file: 种子文件路径
:param episodes: 需要下载的集数
:param channel: 通知渠道
:param source: 通知来源
:param source: 来源消息通知、Subscribe、Manual等
:param downloader: 下载器
:param save_path: 保存路径
:param userid: 用户ID
:param username: 调用下载的用户名/插件名
:param media_category: 自定义媒体类别
"""
# 发送资源下载事件,允许外部拦截下载
event_data = ResourceDownloadEventData(
context=context,
episodes=episodes or context.meta_info.episode_list,
channel=channel,
origin=source,
downloader=downloader,
options={
"save_path": save_path,
"userid": userid,
"username": username,
"media_category": media_category
}
)
# 触发资源下载事件
event = eventmanager.send_event(ChainEventType.ResourceDownload, event_data)
if event and event.event_data:
event_data: ResourceDownloadEventData = event.event_data
# 如果事件被取消,跳过资源下载
if event_data.cancel:
logger.debug(
f"Resource download canceled by event: {event_data.source},"
f"Reason: {event_data.reason}")
return None
_torrent = context.torrent_info
_media = context.media_info
_meta = context.meta_info
@@ -256,7 +283,7 @@ class DownloadChain(ChainBase):
download_dir = Path(save_path)
else:
# 根据媒体信息查询下载目录配置
dir_info = self.directoryhelper.get_dir(_media, storage="local")
dir_info = self.directoryhelper.get_dir(_media, storage="local", include_unsorted=True)
# 拼装子目录
if dir_info:
# 一级目录
@@ -318,7 +345,8 @@ class DownloadChain(ChainBase):
username=username,
channel=channel.value if channel else None,
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
media_category=media_category
media_category=media_category,
note={"source": source}
)
# 登记下载文件
@@ -355,7 +383,9 @@ class DownloadChain(ChainBase):
"hash": _hash,
"context": context,
"username": username,
"downloader": _downloader
"downloader": _downloader,
"episodes": episodes or _meta.episode_list,
"source": source
})
else:
# 下载失败
@@ -364,7 +394,7 @@ class DownloadChain(ChainBase):
# 只发送给对应渠道和用户
self.post_message(Notification(
channel=channel,
source=source,
source=source if channel else None,
mtype=NotificationType.Manual,
title="添加下载任务失败:%s %s"
% (_media.title_year, _meta.season_episode),
@@ -392,7 +422,7 @@ class DownloadChain(ChainBase):
:param no_exists: 缺失的剧集信息
:param save_path: 保存路径
:param channel: 通知渠道
:param source: 通知来源
:param source: 来源(消息通知、订阅、手工下载等)
:param userid: 用户ID
:param username: 调用下载的用户名/插件名
:param media_category: 自定义媒体类别
@@ -457,6 +487,22 @@ class DownloadChain(ChainBase):
return 9999
return no_exist[season].total_episode
# 发送资源选择事件,允许外部修改上下文数据
logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}")
event_data = ResourceSelectionEventData(
contexts=contexts,
downloader=downloader,
origin=source
)
event = eventmanager.send_event(ChainEventType.ResourceSelection, event_data)
# 如果事件修改了上下文数据,使用更新后的数据
if event and event.event_data:
event_data: ResourceSelectionEventData = event.event_data
if event_data.updated and event_data.updated_contexts is not None:
logger.debug(f"Contexts updated by event: "
f"{len(event_data.updated_contexts)} items (source: {event_data.source})")
contexts = event_data.updated_contexts
# 分组排序
contexts = TorrentHelper().sort_group_torrents(contexts)

View File

@@ -426,7 +426,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 电影目录
if is_bluray_folder(fileitem):
# 原盘目录
nfo_path = filepath / "movie.nfo"
nfo_path = filepath / (filepath.name + ".nfo")
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
logger.info(f"已存在nfo文件{nfo_path}")
return
@@ -477,7 +477,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
if not file_meta.begin_episode:
logger.warn(f"{filepath.name} 无法识别文件集数!")
return
file_mediainfo = self.recognize_media(meta=file_meta)
file_mediainfo = self.recognize_media(meta=file_meta, tmdbid=mediainfo.tmdb_id)
if not file_mediainfo:
logger.warn(f"{filepath.name} 无法识别文件媒体信息!")
return
@@ -547,9 +547,34 @@ class MediaChain(ChainBase, metaclass=Singleton):
continue
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
# 保存图片文件到剧集目录
if content:
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
if not parent:
parent = self.storagechain.get_parent_item(fileitem)
__save_file(_fileitem=parent, _path=image_path, _content=content)
# 额外fanart季图片poster thumb banner
image_dict = self.metadata_img(mediainfo=mediainfo)
if image_dict:
for image_name, image_url in image_dict.items():
if image_name.startswith("season"):
image_path = filepath.with_name(image_name)
# 只下载当前刮削季的图片
image_season = "00" if "specials" in image_name else image_name[6:8]
if image_season != str(season_meta.begin_season).rjust(2, '0'):
logger.info(f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
continue
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
path=image_path):
logger.info(f"已存在图片文件:{image_path}")
continue
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
if content:
if not parent:
parent = self.storagechain.get_parent_item(fileitem)
__save_file(_fileitem=parent, _path=image_path, _content=content)
# 判断当前目录是不是剧集根目录
if not season_meta.season:
# 是否已存在
@@ -568,6 +593,9 @@ class MediaChain(ChainBase, metaclass=Singleton):
image_dict = self.metadata_img(mediainfo=mediainfo)
if image_dict:
for image_name, image_url in image_dict.items():
# 不下载季图片
if image_name.startswith("season"):
continue
image_path = filepath / image_name
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
path=image_path):

View File

@@ -111,6 +111,8 @@ class MessageChain(ChainBase):
info = self.message_parser(source=source, body=body, form=form, args=args)
if not info:
return
# 更新消息来源
source = info.source
# 渠道
channel = info.channel
# 用户ID

View File

@@ -87,7 +87,8 @@ class SiteChain(ChainBase):
link=site.get("url")
))
# 低分享率警告
if userdata.ratio and float(userdata.ratio) < 1:
if userdata.ratio and float(userdata.ratio) < 1 and not bool(
re.search(r"(贵宾|VIP?)", userdata.user_level, re.IGNORECASE)):
self.post_message(Notification(
mtype=NotificationType.SiteMessage,
title=f"【站点分享率低预警】",

File diff suppressed because it is too large Load Diff

View File

@@ -330,7 +330,7 @@ class TransferChain(ChainBase):
# 自定义识别
if formaterHandler:
# 开始集、结束集、PART
begin_ep, end_ep, part = formaterHandler.split_episode(file_path.name)
begin_ep, end_ep, part = formaterHandler.split_episode(file_name=file_path.name, file_meta=file_meta)
if begin_ep is not None:
file_meta.begin_episode = begin_ep
file_meta.part = part
@@ -392,29 +392,30 @@ class TransferChain(ChainBase):
download_hash = download_file.download_hash
# 查询整理目标目录
dir_info = None
if not target_directory:
if src_match:
# 按源目录匹配,以便找到更合适的目录配置
target_directory = self.directoryhelper.get_dir(media=file_mediainfo,
storage=file_item.storage,
src_path=file_path,
target_storage=target_storage)
dir_info = self.directoryhelper.get_dir(media=file_mediainfo,
storage=file_item.storage,
src_path=file_path,
target_storage=target_storage)
elif target_path:
# 指定目标路径,`手动整理`场景下使用,忽略源目录匹配,使用指定目录匹配
target_directory = self.directoryhelper.get_dir(media=file_mediainfo,
dest_path=target_path,
target_storage=target_storage)
dir_info = self.directoryhelper.get_dir(media=file_mediainfo,
dest_path=target_path,
target_storage=target_storage)
else:
# 未指定目标路径,根据媒体信息获取目标目录
target_directory = self.directoryhelper.get_dir(file_mediainfo,
storage=target_storage,
target_storage=target_storage)
dir_info = self.directoryhelper.get_dir(file_mediainfo,
storage=file_item.storage,
target_storage=target_storage)
# 执行整理
transferinfo: TransferInfo = self.transfer(fileitem=file_item,
meta=file_meta,
mediainfo=file_mediainfo,
target_directory=target_directory,
target_directory=target_directory or dir_info,
target_storage=target_storage,
target_path=target_path,
transfer_type=transfer_type,
@@ -693,8 +694,8 @@ class TransferChain(ChainBase):
epformat: EpisodeFormat = None,
min_filesize: int = 0,
scrape: bool = None,
library_type_folder: bool = False,
library_category_folder: bool = False,
library_type_folder: bool = None,
library_category_folder: bool = None,
force: bool = False) -> Tuple[bool, Union[str, list]]:
"""
手动整理,支持复杂条件,带进度显示
@@ -759,6 +760,8 @@ class TransferChain(ChainBase):
epformat=epformat,
min_filesize=min_filesize,
scrape=scrape,
library_type_folder=library_type_folder,
library_category_folder=library_category_folder,
force=force)
return state, errmsg

View File

@@ -219,6 +219,10 @@ class ConfigModel(BaseModel):
BIG_MEMORY_MODE: bool = False
# 全局图片缓存,将媒体图片缓存到本地
GLOBAL_IMAGE_CACHE: bool = False
# 是否启用编码探测的性能模式
ENCODING_DETECTION_PERFORMANCE_MODE: bool = True
# 编码探测的最低置信度阈值
ENCODING_DETECTION_MIN_CONFIDENCE: float = 0.8
# 允许的图片缓存域名
SECURITY_IMAGE_DOMAINS: List[str] = Field(
default_factory=lambda: ["image.tmdb.org",
@@ -477,6 +481,7 @@ class Settings(BaseSettings, ConfigModel):
"refresh": 100,
"tmdb": 1024,
"douban": 512,
"bangumi": 512,
"fanart": 512,
"meta": (self.META_CACHE_EXPIRE or 24) * 3600
}
@@ -485,6 +490,7 @@ class Settings(BaseSettings, ConfigModel):
"refresh": 50,
"tmdb": 256,
"douban": 256,
"bangumi": 256,
"fanart": 128,
"meta": (self.META_CACHE_EXPIRE or 2) * 3600
}

View File

@@ -1,5 +1,6 @@
import re
from dataclasses import dataclass, field, asdict
from datetime import datetime
from typing import List, Dict, Any, Tuple
from app.core.config import settings
@@ -123,6 +124,20 @@ class TorrentInfo:
return ""
return StringUtils.diff_time_str(self.freedate)
def pub_minutes(self) -> float:
"""
返回发布时间距离当前时间的分钟数
"""
if not self.pubdate:
return 0
try:
pub_date = datetime.strptime(self.pubdate, "%Y-%m-%d %H:%M:%S")
now_datetime = datetime.now()
return (now_datetime - pub_date).total_seconds() // 60
except Exception as e:
print(f"种子发布时间获取失败: {e}")
return 0
def to_dict(self):
"""
返回字典

View File

@@ -233,23 +233,29 @@ class EventManager(metaclass=Singleton):
可视化所有事件处理器,包括是否被禁用的状态
:return: 处理器列表,包含事件类型、处理器标识符、优先级(如果有)和状态
"""
def parse_handler_data(data):
"""
解析处理器数据,判断是否包含优先级
:param data: 订阅者数据,可能是元组或单一值
:return: (priority, handler),若没有优先级则返回 (None, handler)
"""
if isinstance(data, tuple) and len(data) == 2:
return data
return None, data
handler_info = []
# 统一处理广播事件和链式事件
for event_type, subscribers in {**self.__broadcast_subscribers, **self.__chain_subscribers}.items():
for handler_data in subscribers:
if isinstance(subscribers, dict):
priority, handler = handler_data
else:
priority = None
handler = handler_data
# 获取处理器的唯一标识符
handler_id = self.__get_handler_identifier(handler)
for handler_identifier, handler_data in subscribers.items():
# 解析优先级和处理器
priority, handler = parse_handler_data(handler_data)
# 检查处理器的启用状态
status = "enabled" if self.__is_handler_enabled(handler) else "disabled"
# 构建处理器信息字典
handler_dict = {
"event_type": event_type.value,
"handler_identifier": handler_id,
"handler_identifier": handler_identifier,
"status": status
}
if priority is not None:
@@ -341,8 +347,17 @@ class EventManager(metaclass=Singleton):
if not handlers:
logger.debug(f"No handlers found for chain event: {event}")
return False
# 过滤出启用的处理器
enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()
if self.__is_handler_enabled(handler)}
if not enabled_handlers:
logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.")
return False
self.__log_event_lifecycle(event, "Started")
for handler_id, (priority, handler) in handlers.items():
for handler_id, (priority, handler) in enabled_handlers.items():
start_time = time.time()
self.__safe_invoke_handler(handler, event)
logger.debug(
@@ -487,13 +502,15 @@ class EventManager(metaclass=Singleton):
}
)
def register(self, etype: Union[EventType, ChainEventType, List[Union[EventType, ChainEventType]], type]):
def register(self, etype: Union[EventType, ChainEventType, List[Union[EventType, ChainEventType]], type],
priority: int = DEFAULT_EVENT_PRIORITY):
"""
事件注册装饰器,用于将函数注册为事件的处理器
:param etype:
- 单个事件类型成员 (如 EventType.MetadataScrape, ChainEventType.PluginAction)
- 事件类型类 (EventType, ChainEventType)
- 或事件类型成员的列表
:param priority: 可选,链式事件的优先级,默认为 DEFAULT_EVENT_PRIORITY
"""
def decorator(f: Callable):
@@ -501,23 +518,18 @@ class EventManager(metaclass=Singleton):
if isinstance(etype, list):
# 传入的已经是列表,直接使用
event_list = etype
elif etype is EventType:
# 订阅所有事件
event_list = []
for et in etype:
event_list.append(et)
else:
# 不是列表则包裹成单一元素的列表
event_list = [etype]
# 遍历列表,处理每个事件类型
# 遍历列表,处理每个事件类型
for event in event_list:
if isinstance(event, (EventType, ChainEventType)):
self.add_event_listener(event, f)
self.add_event_listener(event, f, priority)
elif isinstance(event, type) and issubclass(event, (EventType, ChainEventType)):
# 如果是 EventType 或 ChainEventType 类,提取该类中的所有成员
for et in event.__members__.values():
self.add_event_listener(et, f)
self.add_event_listener(et, f, priority)
else:
raise ValueError(f"无效的事件类型: {event}")

View File

@@ -1,11 +1,12 @@
import traceback
from typing import Generator, Optional, Tuple, Any
from typing import Generator, Optional, Tuple, Any, Union
from app.core.config import settings
from app.core.event import eventmanager
from app.helper.module import ModuleHelper
from app.log import logger
from app.schemas.types import EventType, ModuleType
from app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
OtherModulesType
from app.utils.object import ObjectUtils
from app.utils.singleton import Singleton
@@ -19,6 +20,8 @@ class ModuleManager(metaclass=Singleton):
_modules: dict = {}
# 运行态模块列表
_running_modules: dict = {}
# 子模块类型集合
SubType = Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]
def __init__(self):
self.load_modules()
@@ -135,6 +138,17 @@ class ModuleManager(metaclass=Singleton):
and module.get_type() == module_type:
yield module
def get_running_subtype_module(self, module_subtype: SubType) -> Generator:
"""
获取指定子类型的模块
"""
if not self._running_modules:
return []
for _, module in self._running_modules.items():
if hasattr(module, 'get_subtype') \
and module.get_subtype() == module_subtype:
yield module
def get_module(self, module_id: str) -> Any:
"""
根据模块id获取模块

View File

@@ -526,7 +526,8 @@ class PluginManager(metaclass=Singleton):
"name": "服务名称",
"trigger": "触发器cron、interval、date、CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
"kwargs": {} # 定时器参数,
"func_kwargs": {} # 方法参数
}]
"""
ret_services = []

View File

@@ -53,7 +53,9 @@ class DownloadHistory(Base):
@staticmethod
@db_query
def get_by_hash(db: Session, download_hash: str):
return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).first()
return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).order_by(
DownloadHistory.date.desc()
).first()
@staticmethod
@db_query

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON, func
from sqlalchemy import Column, Integer, String, Sequence, Float, JSON, func, or_
from sqlalchemy.orm import Session
from app.db import db_query, Base
@@ -81,7 +81,7 @@ class SiteUserData(Base):
func.max(SiteUserData.updated_day).label('latest_update_day')
)
.group_by(SiteUserData.domain)
.filter(SiteUserData.err_msg == None)
.filter(or_(SiteUserData.err_msg.is_(None), SiteUserData.err_msg == ""))
.subquery()
)

View File

@@ -54,7 +54,7 @@ class Subscribe(Base):
lack_episode = Column(Integer)
# 附加信息
note = Column(JSON)
# 状态N-新建 R-订阅中
# 状态N-新建 R-订阅中 P-待定 S-暂停
state = Column(String, nullable=False, index=True, default='N')
# 最后更新时间
last_update = Column(String)
@@ -98,7 +98,13 @@ class Subscribe(Base):
@staticmethod
@db_query
def get_by_state(db: Session, state: str):
result = db.query(Subscribe).filter(Subscribe.state == state).all()
# 如果 state 为空或 None返回所有订阅
if not state:
result = db.query(Subscribe).all()
else:
# 如果传入的状态不为空,拆分成多个状态
states = state.split(',')
result = db.query(Subscribe).filter(Subscribe.state.in_(states)).all()
return list(result)
@staticmethod

View File

@@ -114,7 +114,8 @@ class SiteOper(DbOper):
"domain": domain,
"name": name,
"updated_day": current_day,
"updated_time": current_time
"updated_time": current_time,
"err_msg": payload.get("err_msg") or ""
})
# 按站点+天判断是否存在数据
siteuserdatas = SiteUserData.get_by_domain(self._db, domain=domain, workdate=current_day)

View File

@@ -83,7 +83,8 @@ class SubscribeOper(DbOper):
更新订阅
"""
subscribe = self.get(sid)
subscribe.update(self._db, payload)
if subscribe:
subscribe.update(self._db, payload)
return subscribe
def list_by_tmdbid(self, tmdbid: int, season: int = None) -> List[Subscribe]:

View File

@@ -16,7 +16,7 @@ class PlaywrightHelper:
"""
sync_stealth(page, pure=True)
page.goto(url)
return sync_cf_retry(page)
return sync_cf_retry(page)[0]
def action(self, url: str,
callback: Callable,

View File

@@ -49,16 +49,16 @@ class DirectoryHelper:
"""
return [d for d in self.get_library_dirs() if d.library_storage == "local"]
def get_dir(self, media: MediaInfo,
def get_dir(self, media: MediaInfo, include_unsorted: bool = False,
storage: str = None, src_path: Path = None,
target_storage: str = None, dest_path: Path = None
) -> Optional[schemas.TransferDirectoryConf]:
"""
根据媒体信息获取下载目录、媒体库目录配置
:param media: 媒体信息
:param include_unsorted: 包含不整理目录
:param storage: 源存储类型
:param target_storage: 目标存储类型
:param fileitem: 文件项,使用文件路径匹配
:param src_path: 源目录,有值时直接匹配
:param dest_path: 目标目录,有值时直接匹配
"""
@@ -73,7 +73,7 @@ class DirectoryHelper:
# 按照配置顺序查找
for d in dirs:
# 没有启用整理的目录
if not d.monitor_type:
if not d.monitor_type and not include_unsorted:
continue
# 源存储类型不匹配
if storage and d.storage != storage:

View File

@@ -3,6 +3,8 @@ from typing import Tuple, Optional
import parse
from app.core.meta.metabase import MetaBase
class FormatParser(object):
_key = ""
@@ -77,7 +79,7 @@ class FormatParser(object):
return True
return False
def split_episode(self, file_name: str) -> Tuple[Optional[int], Optional[int], Optional[str]]:
def split_episode(self, file_name: str, file_meta: MetaBase) -> Tuple[Optional[int], Optional[int], Optional[str]]:
"""
拆分集数返回开始集数结束集数Part信息
"""
@@ -94,7 +96,9 @@ class FormatParser(object):
start_ep = self.__offset.replace("EP", str(self._start_ep))
return int(eval(start_ep)), None, self.part
if not self._format:
return self._start_ep, self._end_ep, self.part
start_ep = eval(self.__offset.replace("EP", str(file_meta.begin_episode))) if file_meta.begin_episode else None
end_ep = eval(self.__offset.replace("EP", str(file_meta.end_episode))) if file_meta.end_episode else None
return int(start_ep) if start_ep else None, int(end_ep) if end_ep else None, self.part
else:
s, e = self.__handle_single(file_name)
start_ep = self.__offset.replace("EP", str(s)) if s else None

View File

@@ -64,10 +64,10 @@ class TorrentHelper(metaclass=Singleton):
if not req.content:
return None, None, "", [], "未下载到种子数据"
# 解析内容格式
if req.text and str(req.text).startswith("magnet:"):
if req.content.startswith(b"magnet:"):
# 磁力链接
return None, req.text, "", [], f"获取到磁力链接"
elif req.text and "下载种子文件" in req.text:
if "下载种子文件".encode("utf-8") in req.content:
# 首次下载提示页面
skip_flag = False
try:

View File

@@ -2,8 +2,9 @@ from abc import abstractmethod, ABCMeta
from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable
from app.helper.service import ServiceConfigHelper
from app.schemas import Notification, MessageChannel, NotificationConf, MediaServerConf, DownloaderConf
from app.schemas.types import ModuleType
from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
OtherModulesType
class _ModuleBase(metaclass=ABCMeta):
@@ -43,6 +44,14 @@ class _ModuleBase(metaclass=ABCMeta):
"""
pass
@staticmethod
@abstractmethod
def get_subtype() -> Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]:
"""
获取模块子类型(下载器、媒体服务器、消息通道、存储类型、其他杂项模块类型)
"""
pass
@staticmethod
@abstractmethod
def get_priority() -> int:

View File

@@ -7,7 +7,7 @@ from app.core.meta import MetaBase
from app.log import logger
from app.modules import _ModuleBase
from app.modules.bangumi.bangumi import BangumiApi
from app.schemas.types import ModuleType
from app.schemas.types import ModuleType, MediaRecognizeType
from app.utils.http import RequestUtils
@@ -44,6 +44,13 @@ class BangumiModule(_ModuleBase):
获取模块类型
"""
return ModuleType.MediaRecognize
@staticmethod
def get_subtype() -> MediaRecognizeType:
"""
获取模块子类型
"""
return MediaRecognizeType.Bangumi
@staticmethod
def get_priority() -> int:

View File

@@ -2,7 +2,9 @@ from datetime import datetime
from functools import lru_cache
import requests
from cachetools import TTLCache, cached
from app.core.config import settings
from app.utils.http import RequestUtils
@@ -28,7 +30,7 @@ class BangumiApi(object):
pass
@classmethod
@lru_cache(maxsize=128)
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["bangumi"], ttl=settings.CACHE_CONF["meta"]))
def __invoke(cls, url, **kwargs):
req_url = cls._base_url + url
params = {}

View File

@@ -15,7 +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, APIRateLimitException
from app.schemas.types import MediaType, ModuleType
from app.schemas.types import MediaType, ModuleType, MediaRecognizeType
from app.utils.common import retry
from app.utils.http import RequestUtils
from app.utils.limit import rate_limit_exponential
@@ -59,6 +59,13 @@ class DoubanModule(_ModuleBase):
"""
return ModuleType.MediaRecognize
@staticmethod
def get_subtype() -> MediaRecognizeType:
"""
获取模块子类型
"""
return MediaRecognizeType.Douban
@staticmethod
def get_priority() -> int:
"""

View File

@@ -175,6 +175,19 @@ class DoubanApi(metaclass=Singleton):
).decode()
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
def __invoke_recommend(self, url: str, **kwargs) -> dict:
"""
推荐/发现类API
"""
return self.__invoke(url, **kwargs)
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
def __invoke_search(self, url: str, **kwargs) -> dict:
"""
搜索类API
"""
return self.__invoke(url, **kwargs)
def __invoke(self, url: str, **kwargs) -> dict:
"""
GET请求
@@ -244,189 +257,189 @@ class DoubanApi(metaclass=Singleton):
"""
关键字搜索
"""
return self.__invoke(self._urls["search"], q=keyword,
start=start, count=count, _ts=ts)
return self.__invoke_search(self._urls["search"], q=keyword,
start=start, count=count, _ts=ts)
def movie_search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电影搜索
"""
return self.__invoke(self._urls["movie_search"], q=keyword,
start=start, count=count, _ts=ts)
return self.__invoke_search(self._urls["movie_search"], q=keyword,
start=start, count=count, _ts=ts)
def tv_search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电视搜索
"""
return self.__invoke(self._urls["tv_search"], q=keyword,
start=start, count=count, _ts=ts)
return self.__invoke_search(self._urls["tv_search"], q=keyword,
start=start, count=count, _ts=ts)
def book_search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
书籍搜索
"""
return self.__invoke(self._urls["book_search"], q=keyword,
start=start, count=count, _ts=ts)
return self.__invoke_search(self._urls["book_search"], q=keyword,
start=start, count=count, _ts=ts)
def group_search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
小组搜索
"""
return self.__invoke(self._urls["group_search"], q=keyword,
start=start, count=count, _ts=ts)
return self.__invoke_search(self._urls["group_search"], q=keyword,
start=start, count=count, _ts=ts)
def person_search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
人物搜索
"""
return self.__invoke(self._urls["search_subject"], type="person", q=keyword,
start=start, count=count, _ts=ts)
return self.__invoke_search(self._urls["search_subject"], type="person", q=keyword,
start=start, count=count, _ts=ts)
def movie_showing(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
正在热映
"""
return self.__invoke(self._urls["movie_showing"],
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["movie_showing"],
start=start, count=count, _ts=ts)
def movie_soon(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
即将上映
"""
return self.__invoke(self._urls["movie_soon"],
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["movie_soon"],
start=start, count=count, _ts=ts)
def movie_hot_gaia(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
热门电影
"""
return self.__invoke(self._urls["movie_hot_gaia"],
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["movie_hot_gaia"],
start=start, count=count, _ts=ts)
def tv_hot(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
热门剧集
"""
return self.__invoke(self._urls["tv_hot"],
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["tv_hot"],
start=start, count=count, _ts=ts)
def tv_animation(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
动画
"""
return self.__invoke(self._urls["tv_animation"],
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["tv_animation"],
start=start, count=count, _ts=ts)
def tv_variety_show(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
综艺
"""
return self.__invoke(self._urls["tv_variety_show"],
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["tv_variety_show"],
start=start, count=count, _ts=ts)
def tv_rank_list(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电视剧排行榜
"""
return self.__invoke(self._urls["tv_rank_list"],
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["tv_rank_list"],
start=start, count=count, _ts=ts)
def show_hot(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
综艺热门
"""
return self.__invoke(self._urls["show_hot"],
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["show_hot"],
start=start, count=count, _ts=ts)
def movie_detail(self, subject_id: str):
"""
电影详情
"""
return self.__invoke(self._urls["movie_detail"] + subject_id)
return self.__invoke_search(self._urls["movie_detail"] + subject_id)
def movie_celebrities(self, subject_id: str):
"""
电影演职员
"""
return self.__invoke(self._urls["movie_celebrities"] % subject_id)
return self.__invoke_search(self._urls["movie_celebrities"] % subject_id)
def tv_detail(self, subject_id: str):
"""
电视剧详情
"""
return self.__invoke(self._urls["tv_detail"] + subject_id)
return self.__invoke_search(self._urls["tv_detail"] + subject_id)
def tv_celebrities(self, subject_id: str):
"""
电视剧演职员
"""
return self.__invoke(self._urls["tv_celebrities"] % subject_id)
return self.__invoke_search(self._urls["tv_celebrities"] % subject_id)
def book_detail(self, subject_id: str):
"""
书籍详情
"""
return self.__invoke(self._urls["book_detail"] + subject_id)
return self.__invoke_search(self._urls["book_detail"] + subject_id)
def movie_top250(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电影TOP250
"""
return self.__invoke(self._urls["movie_top250"],
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["movie_top250"],
start=start, count=count, _ts=ts)
def movie_recommend(self, tags='', sort='R', start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电影探索
"""
return self.__invoke(self._urls["movie_recommend"], tags=tags, sort=sort,
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["movie_recommend"], tags=tags, sort=sort,
start=start, count=count, _ts=ts)
def tv_recommend(self, tags='', sort='R', start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电视剧探索
"""
return self.__invoke(self._urls["tv_recommend"], tags=tags, sort=sort,
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["tv_recommend"], tags=tags, sort=sort,
start=start, count=count, _ts=ts)
def tv_chinese_best_weekly(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
华语口碑周榜
"""
return self.__invoke(self._urls["tv_chinese_best_weekly"],
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["tv_chinese_best_weekly"],
start=start, count=count, _ts=ts)
def tv_global_best_weekly(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
全球口碑周榜
"""
return self.__invoke(self._urls["tv_global_best_weekly"],
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["tv_global_best_weekly"],
start=start, count=count, _ts=ts)
def doulist_detail(self, subject_id: str):
"""
豆列详情
:param subject_id: 豆列id
"""
return self.__invoke(self._urls["doulist"] + subject_id)
return self.__invoke_search(self._urls["doulist"] + subject_id)
def doulist_items(self, subject_id: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
@@ -437,8 +450,8 @@ class DoubanApi(metaclass=Singleton):
:param count: 数量
:param ts: 时间戳
"""
return self.__invoke(self._urls["doulist_items"] % subject_id,
start=start, count=count, _ts=ts)
return self.__invoke_search(self._urls["doulist_items"] % subject_id,
start=start, count=count, _ts=ts)
def movie_recommendations(self, subject_id: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
@@ -449,8 +462,8 @@ class DoubanApi(metaclass=Singleton):
:param count: 数量
:param ts: 时间戳
"""
return self.__invoke(self._urls["movie_recommendations"] % subject_id,
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["movie_recommendations"] % subject_id,
start=start, count=count, _ts=ts)
def tv_recommendations(self, subject_id: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
@@ -461,8 +474,8 @@ class DoubanApi(metaclass=Singleton):
:param count: 数量
:param ts: 时间戳
"""
return self.__invoke(self._urls["tv_recommendations"] % subject_id,
start=start, count=count, _ts=ts)
return self.__invoke_recommend(self._urls["tv_recommendations"] % subject_id,
start=start, count=count, _ts=ts)
def movie_photos(self, subject_id: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
@@ -473,8 +486,8 @@ class DoubanApi(metaclass=Singleton):
:param count: 数量
:param ts: 时间戳
"""
return self.__invoke(self._urls["movie_photos"] % subject_id,
start=start, count=count, _ts=ts)
return self.__invoke_search(self._urls["movie_photos"] % subject_id,
start=start, count=count, _ts=ts)
def tv_photos(self, subject_id: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
@@ -485,8 +498,8 @@ class DoubanApi(metaclass=Singleton):
:param count: 数量
:param ts: 时间戳
"""
return self.__invoke(self._urls["tv_photos"] % subject_id,
start=start, count=count, _ts=ts)
return self.__invoke_search(self._urls["tv_photos"] % subject_id,
start=start, count=count, _ts=ts)
def person_detail(self, subject_id: int):
"""
@@ -494,7 +507,7 @@ class DoubanApi(metaclass=Singleton):
:param subject_id: 人物 id
:return:
"""
return self.__invoke(self._urls["person_detail"] + str(subject_id))
return self.__invoke_search(self._urls["person_detail"] + str(subject_id))
def person_work(self, subject_id: int, start: int = 0, count: int = 20, sort_by: str = "time",
collection_title: str = "影视",
@@ -509,14 +522,16 @@ class DoubanApi(metaclass=Singleton):
:param ts: 时间戳
:return:
"""
return self.__invoke(self._urls["person_work"] % subject_id, sortby=sort_by, collection_title=collection_title,
start=start, count=count, _ts=ts)
return self.__invoke_search(self._urls["person_work"] % subject_id, sortby=sort_by,
collection_title=collection_title,
start=start, count=count, _ts=ts)
def clear_cache(self):
"""
清空LRU缓存
"""
self.__invoke.cache_clear()
# 尚未支持缓存清理
pass
def close(self):
if self._session:

View File

@@ -7,7 +7,7 @@ from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase
from app.modules.emby.emby import Emby
from app.schemas.event import AuthCredentials, AuthInterceptCredentials
from app.schemas.types import MediaType, ModuleType, ChainEventType
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType
class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
@@ -30,6 +30,13 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
"""
return ModuleType.MediaServer
@staticmethod
def get_subtype() -> MediaServerType:
"""
获取模块子类型
"""
return MediaServerType.Emby
@staticmethod
def get_priority() -> int:
"""
@@ -66,16 +73,26 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
logger.info(f"Emby服务器 {name} 连接断开,尝试重连 ...")
server.reconnect()
def user_authenticate(self, credentials: AuthCredentials) -> Optional[AuthCredentials]:
def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \
-> Optional[AuthCredentials]:
"""
使用Emby用户辅助完成用户认证
:param credentials: 认证数据
:param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务
:return: 认证数据
"""
# Emby认证
if not credentials or credentials.grant_type != "password":
return None
for name, server in self.get_instances().items():
# 确定要认证的服务器列表
if service_name:
# 如果指定了服务名,获取该服务实例
servers = [(service_name, server)] if (server := self.get_instance(service_name)) else []
else:
# 如果没有指定服务名,遍历所有服务
servers = self.get_instances().items()
# 遍历要认证的服务器
for name, server in servers:
# 触发认证拦截事件
intercept_event = eventmanager.send_event(
etype=ChainEventType.AuthIntercept,

View File

@@ -6,7 +6,7 @@ from cachetools import TTLCache, cached
from app.core.context import MediaInfo, settings
from app.log import logger
from app.modules import _ModuleBase
from app.schemas.types import MediaType, ModuleType
from app.schemas.types import MediaType, ModuleType, OtherModulesType
from app.utils.http import RequestUtils
@@ -343,6 +343,13 @@ class FanartModule(_ModuleBase):
"""
return ModuleType.Other
@staticmethod
def get_subtype() -> OtherModulesType:
"""
获取模块子类型
"""
return OtherModulesType.Fanart
@staticmethod
def get_priority() -> int:
"""
@@ -377,20 +384,30 @@ class FanartModule(_ModuleBase):
continue
if not isinstance(images, list):
continue
# 按欢迎程度倒排
images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
# 取第一张图片
image_obj = images[0]
# 图片属性xx_path
image_name = self.__name(name)
image_season = image_obj.get('season')
# 设置图片
if image_name.startswith("season") and image_season:
# 季图片格式 seasonxx-poster
image_name = f"season{str(image_season).rjust(2, '0')}-{image_name[6:]}"
if not mediainfo.get_image(image_name):
# 没有图片才设置
mediainfo.set_image(image_name, image_obj.get('url'))
if image_name.startswith("season"):
# 季图片图片格式seasonxx-xxxx/season-specials-xxxx
for image_obj in images:
image_season = image_obj.get('season')
if image_season is not None:
# 包括poster,thumb,banner
if image_season == '0':
season_image = f"season-specials-{image_name[6:]}"
else:
season_image = f"season{str(image_season).rjust(2, '0')}-{image_name[6:]}"
# 设置图片,没有图片才设置
if not mediainfo.get_image(season_image):
mediainfo.set_image(season_image, image_obj.get('url'))
else:
# 其他图片,按欢迎程度倒排
images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
# 取第一张图片
image_obj = images[0]
# 设置图片,没有图片才设置
if not mediainfo.get_image(image_name):
mediainfo.set_image(image_name, image_obj.get('url'))
return mediainfo

View File

@@ -17,8 +17,8 @@ from app.log import logger
from app.modules import _ModuleBase
from app.modules.filemanager.storages import StorageBase
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage
from app.schemas.event import SmartRenameEventData
from app.schemas.types import MediaType, ModuleType, ChainEventType
from app.schemas.event import TransferRenameEventData
from app.schemas.types import MediaType, ModuleType, ChainEventType, OtherModulesType
from app.utils.system import SystemUtils
lock = Lock()
@@ -52,6 +52,13 @@ class FileManagerModule(_ModuleBase):
"""
return ModuleType.Other
@staticmethod
def get_subtype() -> OtherModulesType:
"""
获取模块子类型
"""
return OtherModulesType.FileManager
@staticmethod
def get_priority() -> int:
"""
@@ -695,7 +702,7 @@ class FileManagerModule(_ModuleBase):
return False, errmsg
except Exception as error:
logger.info(f"字幕 {new_file} 出错了,原因: {str(error)}")
return False, ""
return True, ""
def __transfer_audio_track_files(self, fileitem: FileItem, target_storage: str, target_file: Path,
transfer_type: str) -> Tuple[bool, str]:
@@ -719,7 +726,7 @@ class FileManagerModule(_ModuleBase):
file_list: List[FileItem] = storage_oper.list(parent_item)
# 匹配音轨文件
pending_file_list: List[FileItem] = [file for file in file_list
if Path(file.name).stem == org_path.name
if Path(file.name).stem == org_path.stem
and file.type == "file" and file.extension
and f".{file.extension.lower()}" in settings.RMT_AUDIOEXT]
if len(pending_file_list) == 0:
@@ -1213,16 +1220,16 @@ class FileManagerModule(_ModuleBase):
logger.debug(f"Initial render string: {render_str}")
# 发送智能重命名事件
event_data = SmartRenameEventData(
event_data = TransferRenameEventData(
template_string=template_string,
rename_dict=rename_dict,
render_str=render_str,
path=path
)
event = eventmanager.send_event(ChainEventType.SmartRename, event_data)
event = eventmanager.send_event(ChainEventType.TransferRename, event_data)
# 检查事件返回的结果
if event and event.event_data:
event_data: SmartRenameEventData = event.event_data
event_data: TransferRenameEventData = event.event_data
if event_data.updated and event_data.updated_str:
logger.debug(f"Render string updated by event: "
f"{render_str} -> {event_data.updated_str} (source: {event_data.source})")

View File

@@ -553,15 +553,15 @@ class Alist(StorageBase, metaclass=Singleton):
:param new_name: 上传后文件名
:param task: 是否为任务默认为False避免未完成上传时对文件进行操作
"""
encoded_path = UrlUtils.quote(fileitem.path)
encoded_path = UrlUtils.quote(fileitem.path + path.name)
headers = self.__get_header_with_token()
headers.setdefault("Content-Type", "multipart/form-data")
headers.setdefault("Content-Type", "application/octet-stream")
headers.setdefault("As-Task", str(task).lower())
headers.setdefault("File-Path", encoded_path)
with open(path, "rb") as f:
resp: Response = RequestUtils(headers=headers).put_res(
self.__get_api_url("/api/fs/form"),
data={"file": f},
self.__get_api_url("/api/fs/put"),
data=f,
)
if resp.status_code != 200:

View File

@@ -1,4 +1,3 @@
import copy
import json
import subprocess
from pathlib import Path
@@ -57,21 +56,6 @@ class Rclone(StorageBase):
else:
return None
def __get_fileitem(self, path: Path):
"""
获取文件项
"""
return schemas.FileItem(
storage=self.schema.value,
type="file",
path=str(path).replace("\\", "/"),
name=path.name,
basename=path.stem,
extension=path.suffix[1:],
size=path.stat().st_size,
modify_time=path.stat().st_mtime,
)
def __get_rcloneitem(self, item: dict, parent: str = "/") -> schemas.FileItem:
"""
获取rclone文件项
@@ -146,12 +130,12 @@ class Rclone(StorageBase):
retcode = subprocess.run(
[
'rclone', 'mkdir',
f'MP:{fileitem.path}/{name}'
f'MP:{Path(fileitem.path) / name}'
],
startupinfo=self.__get_hidden_shell()
).returncode
if retcode == 0:
return self.get_item(Path(f"{fileitem.path}/{name}"))
return self.get_item(Path(fileitem.path) / name)
except Exception as err:
logger.error(f"rclone创建目录失败{err}")
return None
@@ -200,16 +184,19 @@ class Rclone(StorageBase):
ret = subprocess.run(
[
'rclone', 'lsjson',
f'MP:{path}'
f'MP:{path.parent}'
],
capture_output=True,
startupinfo=self.__get_hidden_shell()
)
if ret.returncode == 0:
items = json.loads(ret.stdout)
return self.__get_rcloneitem(items[0])
for item in items:
if item.get("Name") == path.name:
return self.__get_rcloneitem(item, parent=str(path.parent) + "/")
return None
except Exception as err:
logger.error(f"rclone获取文件失败{err}")
logger.debug(f"rclone获取文件失败:{err}")
return None
def delete(self, fileitem: schemas.FileItem) -> bool:
@@ -239,7 +226,7 @@ class Rclone(StorageBase):
[
'rclone', 'moveto',
f'MP:{fileitem.path}',
f'MP:{Path(fileitem.path).parent}/{name}'
f'MP:{Path(fileitem.path).parent / name}'
],
startupinfo=self.__get_hidden_shell()
).returncode
@@ -287,7 +274,7 @@ class Rclone(StorageBase):
startupinfo=self.__get_hidden_shell()
).returncode
if retcode == 0:
return self.__get_fileitem(new_path)
return self.get_item(new_path)
except Exception as err:
logger.error(f"rclone上传文件失败{err}")
return None

View File

@@ -7,7 +7,7 @@ from app.helper.rule import RuleHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.filter.RuleParser import RuleParser
from app.schemas.types import ModuleType
from app.schemas.types import ModuleType, OtherModulesType
from app.utils.string import StringUtils
@@ -167,6 +167,13 @@ class FilterModule(_ModuleBase):
"""
return ModuleType.Other
@staticmethod
def get_subtype() -> OtherModulesType:
"""
获取模块子类型
"""
return OtherModulesType.Filter
@staticmethod
def get_priority() -> int:
"""
@@ -359,6 +366,8 @@ class FilterModule(_ModuleBase):
seeders = self.rule_set[rule_name].get("seeders")
# FREE规则
downloadvolumefactor = self.rule_set[rule_name].get("downloadvolumefactor")
# 发布时间规则
pubdate: str = self.rule_set[rule_name].get("publish_time")
if includes and not any(re.search(r"%s" % include, content, re.IGNORECASE) for include in includes):
# 未发现任何包含项
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 不包含任何项 {includes}")
@@ -385,6 +394,22 @@ class FilterModule(_ModuleBase):
logger.debug(
f"种子 {torrent.site_name} - {torrent.title} FREE值 {torrent.downloadvolumefactor} 不是 {downloadvolumefactor}")
return False
if pubdate:
# 种子发布时间
pub_minutes = torrent.pub_minutes()
# 发布时间规则
pub_times = [float(t) for t in pubdate.split("-")]
if len(pub_times) == 1:
# 发布时间小于规则
if pub_minutes < pub_times[0]:
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 发布时间 {pub_minutes} 小于 {pub_times[0]}")
return False
else:
# 区间
if not (pub_times[0] <= pub_minutes <= pub_times[1]):
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 发布时间 {pub_minutes} 不在 {pub_times[0]}-{pub_times[1]} 时间区间")
return False
return True
def __match_tmdb(self, tmdb: dict) -> bool:

View File

@@ -18,7 +18,7 @@ from app.modules.indexer.spider.tnode import TNodeSpider
from app.modules.indexer.spider.torrentleech import TorrentLeech
from app.modules.indexer.spider.yema import YemaSpider
from app.schemas import SiteUserData
from app.schemas.types import MediaType, ModuleType
from app.schemas.types import MediaType, ModuleType, OtherModulesType
from app.utils.string import StringUtils
@@ -47,6 +47,13 @@ class IndexerModule(_ModuleBase):
"""
return ModuleType.Indexer
@staticmethod
def get_subtype() -> OtherModulesType:
"""
获取模块子类型
"""
return OtherModulesType.Indexer
@staticmethod
def get_priority() -> int:
"""

View File

@@ -344,11 +344,9 @@ class SiteParserBase(metaclass=ABCMeta):
logger.warn(
f"{self._site_name} 检测到Cloudflare请更新Cookie和UA")
return ""
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
res.encoding = "utf-8"
else:
res.encoding = res.apparent_encoding
return res.text
return RequestUtils.get_decoded_html_content(res,
settings.ENCODING_DETECTION_PERFORMANCE_MODE,
settings.ENCODING_DETECTION_MIN_CONFIDENCE)
return ""

View File

@@ -5,7 +5,6 @@ import traceback
from typing import List
from urllib.parse import quote, urlencode, urlparse, parse_qs
import chardet
from jinja2 import Template
from pyquery import PyQuery
from ruamel.yaml import CommentedMap
@@ -250,27 +249,9 @@ class TorrentSpider:
referer=self.referer,
proxies=self.proxies
).get_res(searchurl, allow_redirects=True)
if ret is not None:
# 使用chardet检测字符编码
raw_data = ret.content
if raw_data:
try:
result = chardet.detect(raw_data)
encoding = result['encoding']
# 解码为字符串
page_source = raw_data.decode(encoding)
except Exception as e:
logger.debug(f"chardet解码失败{str(e)}")
# 探测utf-8解码
if re.search(r"charset=\"?utf-8\"?", ret.text, re.IGNORECASE):
ret.encoding = "utf-8"
else:
ret.encoding = ret.apparent_encoding
page_source = ret.text
else:
page_source = ret.text
else:
page_source = ""
page_source = RequestUtils.get_decoded_html_content(ret,
settings.ENCODING_DETECTION_PERFORMANCE_MODE,
settings.ENCODING_DETECTION_MIN_CONFIDENCE)
# 解析
return self.parse(page_source)

View File

@@ -97,7 +97,7 @@ class YemaSpider:
results = res.json().get('data', []) or []
for result in results:
category_value = result.get('categoryId')
if category_value in self._tv_category :
if category_value in self._tv_category:
category = MediaType.TV.value
elif category_value in self._movie_category:
category = MediaType.MOVIE.value

View File

@@ -7,7 +7,7 @@ from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase
from app.modules.jellyfin.jellyfin import Jellyfin
from app.schemas.event import AuthCredentials, AuthInterceptCredentials
from app.schemas.types import MediaType, ModuleType, ChainEventType
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType
class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
@@ -30,6 +30,13 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
"""
return ModuleType.MediaServer
@staticmethod
def get_subtype() -> MediaServerType:
"""
获取模块子类型
"""
return MediaServerType.Jellyfin
@staticmethod
def get_priority() -> int:
"""
@@ -66,16 +73,26 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
return False, f"无法连接Jellyfin服务器{name}"
return True, ""
def user_authenticate(self, credentials: AuthCredentials) -> Optional[AuthCredentials]:
def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \
-> Optional[AuthCredentials]:
"""
使用Jellyfin用户辅助完成用户认证
:param credentials: 认证数据
:param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务
:return: 认证数据
"""
# Jellyfin认证
if not credentials or credentials.grant_type != "password":
return None
for name, server in self.get_instances().items():
# 确定要认证的服务器列表
if service_name:
# 如果指定了服务名,获取该服务实例
servers = [(service_name, server)] if (server := self.get_instance(service_name)) else []
else:
# 如果没有指定服务名,遍历所有服务
servers = self.get_instances().items()
# 遍历要认证的服务器
for name, server in servers:
# 触发认证拦截事件
intercept_event = eventmanager.send_event(
etype=ChainEventType.AuthIntercept,

View File

@@ -7,7 +7,7 @@ from app.log import logger
from app.modules import _ModuleBase, _MediaServerBase
from app.modules.plex.plex import Plex
from app.schemas.event import AuthCredentials, AuthInterceptCredentials
from app.schemas.types import MediaType, ModuleType, ChainEventType
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType
class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
@@ -30,6 +30,13 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
"""
return ModuleType.MediaServer
@staticmethod
def get_subtype() -> MediaServerType:
"""
获取模块子类型
"""
return MediaServerType.Plex
@staticmethod
def get_priority() -> int:
"""
@@ -66,16 +73,26 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
logger.info(f"Plex {name} 服务器连接断开,尝试重连 ...")
server.reconnect()
def user_authenticate(self, credentials: AuthCredentials) -> Optional[AuthCredentials]:
def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \
-> Optional[AuthCredentials]:
"""
使用Plex用户辅助完成用户认证
:param credentials: 认证数据
:param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务
:return: 认证数据
"""
# Plex认证
if not credentials or credentials.grant_type != "password":
return None
for name, server in self.get_instances().items():
# 确定要认证的服务器列表
if service_name:
# 如果指定了服务名,获取该服务实例
servers = [(service_name, server)] if (server := self.get_instance(service_name)) else []
else:
# 如果没有指定服务名,遍历所有服务
servers = self.get_instances().items()
# 遍历要认证的服务器
for name, server in servers:
# 触发认证拦截事件
intercept_event = eventmanager.send_event(
etype=ChainEventType.AuthIntercept,

View File

@@ -11,7 +11,7 @@ from app.log import logger
from app.modules import _ModuleBase, _DownloaderBase
from app.modules.qbittorrent.qbittorrent import Qbittorrent
from app.schemas import TransferTorrent, DownloadingTorrent
from app.schemas.types import TorrentStatus, ModuleType
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType
from app.utils.string import StringUtils
@@ -35,6 +35,13 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
"""
return ModuleType.Downloader
@staticmethod
def get_subtype() -> DownloaderType:
"""
获取模块子类型
"""
return DownloaderType.Qbittorrent
@staticmethod
def get_priority() -> int:
"""

View File

@@ -31,6 +31,13 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
"""
return ModuleType.Notification
@staticmethod
def get_subtype() -> MessageChannel:
"""
获取模块子类型
"""
return MessageChannel.Slack
@staticmethod
def get_priority() -> int:
"""

View File

@@ -10,7 +10,7 @@ from app.core.context import Context
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.modules import _ModuleBase
from app.schemas.types import ModuleType
from app.schemas.types import ModuleType, OtherModulesType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
@@ -40,6 +40,13 @@ class SubtitleModule(_ModuleBase):
"""
return ModuleType.Other
@staticmethod
def get_subtype() -> OtherModulesType:
"""
获取模块子类型
"""
return OtherModulesType.Subtitle
@staticmethod
def get_priority() -> int:
"""

View File

@@ -29,6 +29,13 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
"""
return ModuleType.Notification
@staticmethod
def get_subtype() -> MessageChannel:
"""
获取模块子类型
"""
return MessageChannel.SynologyChat
@staticmethod
def get_priority() -> int:
"""

View File

@@ -34,6 +34,13 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
"""
return ModuleType.Notification
@staticmethod
def get_subtype() -> MessageChannel:
"""
获取模块子类型
"""
return MessageChannel.Telegram
@staticmethod
def get_priority() -> int:
"""

View File

@@ -14,7 +14,7 @@ from app.modules.themoviedb.scraper import TmdbScraper
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.modules.themoviedb.tmdbapi import TmdbApi
from app.schemas import MediaPerson
from app.schemas.types import MediaType, MediaImageType, ModuleType
from app.schemas.types import MediaType, MediaImageType, ModuleType, MediaRecognizeType
from app.utils.http import RequestUtils
@@ -49,6 +49,13 @@ class TheMovieDbModule(_ModuleBase):
"""
return ModuleType.MediaRecognize
@staticmethod
def get_subtype() -> MediaRecognizeType:
"""
获取模块子类型
"""
return MediaRecognizeType.TMDB
@staticmethod
def get_priority() -> int:
"""

View File

@@ -32,7 +32,7 @@ class TmdbScraper:
else:
if season is not None:
# 查询季信息
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, meta.begin_season)
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, season)
if episode:
# 集元数据文件
episodeinfo = self.__get_episode_detail(seasoninfo, meta.begin_episode)
@@ -102,7 +102,11 @@ class TmdbScraper:
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}"
# S0海报格式不同
if season == 0:
image_name = f"season-specials-poster{ext}"
else:
image_name = f"season{sea_seq}-poster{ext}"
return image_name, url
return "", ""
@@ -229,7 +233,7 @@ class TmdbScraper:
xoutline = DomUtils.add_node(doc, root, "outline")
xoutline.appendChild(doc.createCDATASection(seasoninfo.get("overview") or ""))
# 标题
DomUtils.add_node(doc, root, "title", "%s" % season)
DomUtils.add_node(doc, root, "title", seasoninfo.get("name") or "%s" % season)
# 发行日期
DomUtils.add_node(doc, root, "premiered", seasoninfo.get("air_date") or "")
DomUtils.add_node(doc, root, "releasedate", seasoninfo.get("air_date") or "")

View File

@@ -161,7 +161,7 @@ class TmdbApi:
season_number: int = None) -> Optional[dict]:
"""
搜索tmdb中的媒体信息匹配返回一条尽可能正确的信息
:param name: 索的名称
:param name: 索的名称
:param mtype: 类型:电影、电视剧
:param year: 年份,如要是季集需要是首播年份(first_air_date)
:param season_year: 当前季集年份

View File

@@ -4,7 +4,7 @@ from app.core.config import settings
from app.log import logger
from app.modules import _ModuleBase
from app.modules.thetvdb import tvdbapi
from app.schemas.types import ModuleType
from app.schemas.types import ModuleType, MediaRecognizeType
from app.utils.http import RequestUtils
@@ -28,6 +28,13 @@ class TheTvDbModule(_ModuleBase):
"""
return ModuleType.MediaRecognize
@staticmethod
def get_subtype() -> MediaRecognizeType:
"""
获取模块子类型
"""
return MediaRecognizeType.TVDB
@staticmethod
def get_priority() -> int:
"""

View File

@@ -11,7 +11,7 @@ from app.log import logger
from app.modules import _ModuleBase, _DownloaderBase
from app.modules.transmission.transmission import Transmission
from app.schemas import TransferTorrent, DownloadingTorrent
from app.schemas.types import TorrentStatus, ModuleType
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType
from app.utils.string import StringUtils
@@ -35,6 +35,13 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
"""
return ModuleType.Downloader
@staticmethod
def get_subtype() -> DownloaderType:
"""
获取模块子类型
"""
return DownloaderType.Transmission
@staticmethod
def get_priority() -> int:
"""

View File

@@ -30,6 +30,13 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
"""
return ModuleType.Notification
@staticmethod
def get_subtype() -> MessageChannel:
"""
获取模块子类型
"""
return MessageChannel.VoceChat
@staticmethod
def get_priority() -> int:
"""

View File

@@ -30,6 +30,13 @@ class WebPushModule(_ModuleBase, _MessageBase):
"""
return ModuleType.Notification
@staticmethod
def get_subtype() -> MessageChannel:
"""
获取模块子类型
"""
return MessageChannel.WebPush
@staticmethod
def get_priority() -> int:
"""

View File

@@ -36,6 +36,13 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
"""
return ModuleType.Notification
@staticmethod
def get_subtype() -> MessageChannel:
"""
获取模块的子类型
"""
return MessageChannel.Wechat
@staticmethod
def get_priority() -> int:
"""

View File

@@ -225,7 +225,7 @@ class _PluginBase(metaclass=ABCMeta):
return self.plugindata.del_data(plugin_id, key)
def post_message(self, channel: MessageChannel = None, mtype: NotificationType = None, title: str = None,
text: str = None, image: str = None, link: str = None, userid: str = None):
text: str = None, image: str = None, link: str = None, userid: str = None, username: str = None):
"""
发送消息
"""
@@ -233,7 +233,7 @@ class _PluginBase(metaclass=ABCMeta):
link = settings.MP_DOMAIN(f"#/plugins?tab=installed&id={self.__class__.__name__}")
self.chain.post_message(Notification(
channel=channel, mtype=mtype, title=title, text=text,
image=image, link=link, userid=userid
image=image, link=link, userid=userid, username=username
))
def close(self):

View File

@@ -41,7 +41,7 @@ class Scheduler(metaclass=Singleton):
# 退出事件
_event = threading.Event()
# 锁
_lock = threading.Lock()
_lock = threading.RLock()
# 各服务的运行状态
_jobs = {}
# 用户认证失败次数
@@ -54,53 +54,6 @@ class Scheduler(metaclass=Singleton):
"""
初始化定时服务
"""
def clear_cache():
"""
清理缓存
"""
TorrentsChain().clear_cache()
SchedulerChain().clear_cache()
def user_auth():
"""
用户认证检查
"""
if SitesHelper().auth_level >= 2:
return
# 最大重试次数
__max_try__ = 30
if self._auth_count > __max_try__:
SchedulerChain().messagehelper.put(title=f"用户认证失败",
message="用户认证失败次数过多,将不再尝试认证!",
role="system")
return
logger.info("用户未认证,正在尝试认证...")
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
if auth_conf:
status, msg = SitesHelper().check_user(**auth_conf)
else:
status, msg = SitesHelper().check_user()
if status:
self._auth_count = 0
logger.info(f"{msg} 用户认证成功")
SchedulerChain().post_message(
Notification(
mtype=NotificationType.Manual,
title="MoviePilot用户认证成功",
text=f"使用站点:{msg}",
link=settings.MP_DOMAIN('#/site')
)
)
PluginManager().init_config()
self.init_plugin_jobs()
else:
self._auth_count += 1
logger.error(f"用户认证失败:{msg},共失败 {self._auth_count}")
if self._auth_count >= __max_try__:
logger.error("用户认证失败次数过多,将不再尝试认证!")
# 各服务的运行状态
self._jobs = {
"cookiecloud": {
@@ -146,12 +99,12 @@ class Scheduler(metaclass=Singleton):
},
"clear_cache": {
"name": "缓存清理",
"func": clear_cache,
"func": self.clear_cache,
"running": False,
},
"user_auth": {
"name": "用户认证检查",
"func": user_auth,
"func": self.user_auth,
"running": False,
},
"scheduler_job": {
@@ -344,17 +297,18 @@ class Scheduler(metaclass=Singleton):
}
)
# 站点数据刷新每隔30分钟
self._scheduler.add_job(
self.start,
"interval",
id="sitedata_refresh",
name="站点数据刷新",
minutes=settings.SITEDATA_REFRESH_INTERVAL * 60,
kwargs={
'job_id': 'sitedata_refresh'
}
)
# 站点数据刷新
if settings.SITEDATA_REFRESH_INTERVAL:
self._scheduler.add_job(
self.start,
"interval",
id="sitedata_refresh",
name="站点数据刷新",
minutes=settings.SITEDATA_REFRESH_INTERVAL * 60,
kwargs={
'job_id': 'sitedata_refresh'
}
)
self.init_plugin_jobs()
@@ -434,11 +388,13 @@ class Scheduler(metaclass=Singleton):
try:
sid = f"{service['id']}"
job_id = sid.split("|")[0]
self.remove_plugin_job(pid, job_id)
self._jobs[job_id] = {
"func": service["func"],
"name": service["name"],
"pid": pid,
"plugin_name": plugin_name,
"kwargs": service.get("func_kwargs") or {},
"running": False,
}
self._scheduler.add_job(
@@ -446,7 +402,7 @@ class Scheduler(metaclass=Singleton):
service["trigger"],
id=sid,
name=service["name"],
**service["kwargs"],
**(service.get("kwargs") or {}),
kwargs={"job_id": job_id},
replace_existing=True
)
@@ -457,23 +413,34 @@ class Scheduler(metaclass=Singleton):
message=str(e),
role="system")
def remove_plugin_job(self, pid: str):
def remove_plugin_job(self, pid: str, job_id: str = None):
"""
移除插件定时服务
移除定时服务,可以是单个服务(包括默认服务)或整个插件的所有服务
:param pid: 插件 ID
:param job_id: 可选,指定要移除的单个服务的 job_id。如果不提供则移除该插件的所有服务当移除单个服务时默认服务也包含在内
"""
if not self._scheduler:
return
with self._lock:
# 获取插件名称
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
# 先从 _jobs 中查找匹配的服务
jobs_to_remove = [(job_id, service) for job_id, service in self._jobs.items() if service.get("pid") == pid]
if job_id:
# 移除单个服务
service = self._jobs.pop(job_id, None)
if not service:
return
jobs_to_remove = [(job_id, service)]
else:
# 移除插件的所有服务
jobs_to_remove = [
(job_id, service) for job_id, service in self._jobs.items() if service.get("pid") == pid
]
for job_id, _ in jobs_to_remove:
self._jobs.pop(job_id, None)
if not jobs_to_remove:
return
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
# 遍历移除任务
for job_id, service in jobs_to_remove:
try:
# 移除服务
self._jobs.pop(job_id, None)
# 在调度器中查找并移除对应的 job
job_removed = False
for job in list(self._scheduler.get_jobs()):
@@ -557,3 +524,50 @@ class Scheduler(metaclass=Singleton):
logger.info("定时任务停止完成")
except Exception as e:
logger.error(f"停止定时任务失败::{str(e)} - {traceback.format_exc()}")
@staticmethod
def clear_cache():
"""
清理缓存
"""
TorrentsChain().clear_cache()
SchedulerChain().clear_cache()
def user_auth(self):
"""
用户认证检查
"""
if SitesHelper().auth_level >= 2:
return
# 最大重试次数
__max_try__ = 30
if self._auth_count > __max_try__:
SchedulerChain().messagehelper.put(title=f"用户认证失败",
message="用户认证失败次数过多,将不再尝试认证!",
role="system")
return
logger.info("用户未认证,正在尝试认证...")
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
if auth_conf:
status, msg = SitesHelper().check_user(**auth_conf)
else:
status, msg = SitesHelper().check_user()
if status:
self._auth_count = 0
logger.info(f"{msg} 用户认证成功")
SchedulerChain().post_message(
Notification(
mtype=NotificationType.Manual,
title="MoviePilot用户认证成功",
text=f"使用站点:{msg}",
link=settings.MP_DOMAIN('#/site')
)
)
PluginManager().init_config()
self.init_plugin_jobs()
else:
self._auth_count += 1
logger.error(f"用户认证失败:{msg},共失败 {self._auth_count}")
if self._auth_count >= __max_try__:
logger.error("用户认证失败次数过多,将不再尝试认证!")

View File

@@ -1,8 +1,11 @@
from pathlib import Path
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List, Set
from pydantic import BaseModel, Field, root_validator
from app.core.context import Context
from app.schemas import MessageChannel
class BaseEventData(BaseModel):
"""
@@ -117,9 +120,9 @@ class CommandRegisterEventData(ChainEventData):
source: str = Field("未知拦截源", description="拦截源")
class SmartRenameEventData(ChainEventData):
class TransferRenameEventData(ChainEventData):
"""
SmartRename 事件的数据模型
TransferRename 事件的数据模型
Attributes:
# 输入参数
@@ -143,3 +146,60 @@ class SmartRenameEventData(ChainEventData):
updated: bool = Field(False, description="是否已更新")
updated_str: Optional[str] = Field(None, description="更新后的字符串")
source: Optional[str] = Field("未知拦截源", description="拦截源")
class ResourceSelectionEventData(BaseModel):
"""
ResourceSelection 事件的数据模型
Attributes:
# 输入参数
contexts (List[Context]): 当前待选择的资源上下文列表
source (str): 事件源,指示事件的触发来源
# 输出参数
updated (bool): 是否已更新,默认值为 False
updated_contexts (Optional[List[Context]]): 已更新的资源上下文列表,默认值为 None
source (str): 更新源,默认值为 "未知更新源"
"""
# 输入参数
contexts: Any = Field(None, description="待选择的资源上下文列表")
downloader: Optional[str] = Field(None, description="下载器")
origin: Optional[str] = Field(None, description="来源")
# 输出参数
updated: bool = Field(False, description="是否已更新")
updated_contexts: Optional[List[Context]] = Field(None, description="已更新的资源上下文列表")
source: Optional[str] = Field("未知拦截源", description="拦截源")
class ResourceDownloadEventData(ChainEventData):
"""
ResourceDownload 事件的数据模型
Attributes:
# 输入参数
context (Context): 当前资源上下文
episodes (Set[int]): 需要下载的集数
channel (MessageChannel): 通知渠道
origin (str): 来源消息通知、Subscribe、Manual等
downloader (str): 下载器
options (dict): 其他参数
# 输出参数
cancel (bool): 是否取消下载,默认值为 False
source (str): 拦截源,默认值为 "未知拦截源"
reason (str): 拦截原因,描述拦截的具体原因
"""
# 输入参数
context: Any = Field(None, description="当前资源上下文")
episodes: Optional[Set[int]] = Field(None, description="需要下载的集数")
channel: Optional[MessageChannel] = Field(None, description="通知渠道")
origin: Optional[str] = Field(None, description="来源")
downloader: Optional[str] = Field(None, description="下载器")
options: Optional[dict] = Field(None, description="其他参数")
# 输出参数
cancel: bool = Field(False, description="是否取消下载")
source: str = Field("未知拦截源", description="拦截源")
reason: str = Field("", description="拦截原因")

View File

@@ -89,3 +89,42 @@ class EpisodeFormat(BaseModel):
detail: Optional[str] = None
part: Optional[str] = None
offset: Optional[str] = None
class ManualTransferItem(BaseModel):
# 文件项
fileitem: FileItem = None
# 日志ID
logid: Optional[int] = None
# 目标存储
target_storage: Optional[str] = None
# 目标路径
target_path: Optional[str] = None
# TMDB ID
tmdbid: Optional[int] = None
# 豆瓣ID
doubanid: Optional[str] = None
# 类型
type_name: Optional[str] = None
# 季号
season: Optional[int] = None
# 整理方式
transfer_type: Optional[str] = None
# 自定义格式
episode_format: Optional[str] = None
# 指定集数
episode_detail: Optional[str] = None
# 指定PART
episode_part: Optional[str] = None
# 集数偏移
episode_offset: Optional[str] = None
# 最小文件大小
min_filesize: Optional[int] = 0
# 刮削
scrape: bool = False
# 媒体库类型子目录
library_type_folder: Optional[bool] = None
# 媒体库类别子目录
library_category_folder: Optional[bool] = None
# 复用历史识别信息
from_history: Optional[bool] = False

View File

@@ -48,6 +48,8 @@ class EventType(Enum):
NoticeMessage = "notice.message"
# 订阅已添加
SubscribeAdded = "subscribe.added"
# 订阅已删除
SubscribeDeleted = "subscribe.deleted"
# 订阅已完成
SubscribeComplete = "subscribe.complete"
# 系统错误
@@ -68,8 +70,12 @@ class ChainEventType(Enum):
AuthIntercept = "auth.intercept"
# 命令注册
CommandRegister = "command.register"
# 智能重命名
SmartRename = "SmartRename"
# 整理重命名
TransferRename = "transfer.rename"
# 资源选择
ResourceSelection = "resource.selection"
# 资源下载
ResourceDownload = "resource.download"
# 系统配置Key字典
@@ -178,6 +184,52 @@ class MessageChannel(Enum):
WebPush = "WebPush"
# 下载器类型
class DownloaderType(Enum):
# Qbittorrent
Qbittorrent = "Qbittorrent"
# Transmission
Transmission = "Transmission"
# Aria2
# Aria2 = "Aria2"
# 媒体服务器类型
class MediaServerType(Enum):
# Emby
Emby = "Emby"
# Jellyfin
Jellyfin = "Jellyfin"
# Plex
Plex = "Plex"
# 识别器类型
class MediaRecognizeType(Enum):
# 豆瓣
Douban = "豆瓣"
# TMDB
TMDB = "TheMovieDb"
# TVDB
TVDB = "TheTvDb"
# bangumi
Bangumi = "Bangumi"
# 其他杂项模块类型
class OtherModulesType(Enum):
# 字幕
Subtitle = "站点字幕"
# Fanart
Fanart = "Fanart"
# 文件整理
FileManager = "文件整理"
# 过滤器
Filter = "过滤器"
# 站点索引
Indexer = "站点索引"
# 用户配置Key字典
class UserConfigKey(Enum):
# 监控面板

View File

@@ -1,5 +1,7 @@
import re
from typing import Any, Optional, Union
import chardet
import requests
import urllib3
from requests import Response, Session
@@ -273,3 +275,108 @@ class RequestUtils:
cache_headers["Cache-Control"] = f"max-age={max_age}"
return cache_headers
@staticmethod
def detect_encoding_from_html_response(response: Response,
performance_mode: bool = False, confidence_threshold: float = 0.8):
"""
根据HTML响应内容探测编码信息
:param response: HTTP 响应对象
:param performance_mode: 是否使用性能模式,默认为 False (兼容模式)
:param confidence_threshold: chardet 检测置信度阈值,默认为 0.8
:return: 解析得到的字符编码
"""
fallback_encoding = None
try:
if not performance_mode:
# 兼容模式使用chardet分析后再处理 BOM 和 meta 信息
# 1. 使用 chardet 库进一步分析内容
detection = chardet.detect(response.content)
if detection["confidence"] > confidence_threshold:
return detection.get("encoding")
# 保存 chardet 的结果备用
fallback_encoding = detection.get("encoding")
# 2. 检查响应体中的 BOM 标记(例如 UTF-8 BOM
if response.content[:3] == b"\xef\xbb\xbf": # UTF-8 BOM
return "utf-8"
# 3. 如果是 HTML 响应体,检查其中的 <meta charset="..."> 标签
if re.search(r"charset=[\"']?utf-8[\"']?", response.text, re.IGNORECASE):
return "utf-8"
# 4. 尝试从 response headers 中获取编码信息
content_type = response.headers.get("Content-Type", "")
if re.search(r"charset=[\"']?utf-8[\"']?", content_type, re.IGNORECASE):
return "utf-8"
else:
# 性能模式:优先从 headers 和 BOM 标记获取,最后使用 chardet 分析
# 1. 尝试从 response headers 中获取编码信息
content_type = response.headers.get("Content-Type", "")
if re.search(r"charset=[\"']?utf-8[\"']?", content_type, re.IGNORECASE):
return "utf-8"
# 暂不支持直接提取字符集仅提取UTF8
# match = re.search(r"charset=[\"']?([^\"';\s]+)", content_type, re.IGNORECASE)
# if match:
# return match.group(1)
# 2. 检查响应体中的 BOM 标记(例如 UTF-8 BOM
if response.content[:3] == b"\xef\xbb\xbf":
return "utf-8"
# 3. 如果是 HTML 响应体,检查其中的 <meta charset="..."> 标签
if re.search(r"charset=[\"']?utf-8[\"']?", response.text, re.IGNORECASE):
return "utf-8"
# 暂不支持直接提取字符集仅提取UTF8
# match = re.search(r"<meta[^>]+charset=[\"']?([^\"'>\s]+)", response.text, re.IGNORECASE)
# if match:
# return match.group(1)
# 4. 使用 chardet 库进一步分析内容
detection = chardet.detect(response.content)
if detection.get("confidence", 0) > confidence_threshold:
return detection.get("encoding")
# 保存 chardet 的结果备用
fallback_encoding = detection.get("encoding")
# 5. 如果上述方法都无法确定,信任 chardet 的结果(即使置信度较低),否则返回默认字符集
return fallback_encoding or "utf-8"
except Exception as e:
logger.debug(f"Error when detect_encoding_from_response: {str(e)}")
return fallback_encoding or "utf-8"
@staticmethod
def get_decoded_html_content(response: Response,
performance_mode: bool = False, confidence_threshold: float = 0.8) -> str:
"""
获取HTML响应的解码文本内容
:param response: HTTP 响应对象
:param performance_mode: 是否使用性能模式,默认为 False (兼容模式)
:param confidence_threshold: chardet 检测置信度阈值,默认为 0.8
:return: 解码后的响应文本内容
"""
try:
if not response:
return ""
if response.content:
# 1. 获取编码信息
encoding = (RequestUtils.detect_encoding_from_html_response(response, performance_mode,
confidence_threshold)
or response.apparent_encoding)
# 2. 根据解析得到的编码进行解码
try:
# 尝试用推测的编码解码
return response.content.decode(encoding)
except Exception as e:
logger.debug(f"Decoding failed, error message: {str(e)}")
# 如果解码失败,尝试 fallback 使用 apparent_encoding
response.encoding = response.apparent_encoding
return response.text
else:
return response.text
except Exception as e:
logger.debug(f"Error when getting decoded content: {str(e)}")
return response.text

View File

@@ -58,6 +58,8 @@ pystray~=0.19.5
pyotp~=2.9.0
Pinyin2Hanzi~=0.1.1
pywebpush~=2.0.0
python-115~=0.0.9.8.7
python-115==0.0.9.8.8.2
p115client==0.0.3.8.3.3
python-cookietools==0.0.2.1
aligo~=6.2.4
aiofiles~=24.1.0

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.1.0'
FRONTEND_VERSION = 'v2.1.0'
APP_VERSION = 'v2.1.4'
FRONTEND_VERSION = 'v2.1.4'