mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-08 01:03:08 +08:00
Compare commits
237 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
397a8a9536 | ||
|
|
1da0a706a3 | ||
|
|
4f2a110b5f | ||
|
|
bb356ffcee | ||
|
|
6c986416ca | ||
|
|
951ec138ef | ||
|
|
23e779ed94 | ||
|
|
29fccd3887 | ||
|
|
1bef723332 | ||
|
|
3c41fed0ef | ||
|
|
5947d0e6d0 | ||
|
|
0e4fa86372 | ||
|
|
f32405b646 | ||
|
|
13955dafe3 | ||
|
|
eaca396a9f | ||
|
|
fabd9f2f75 | ||
|
|
0d8480769f | ||
|
|
dc850f1c48 | ||
|
|
fb311f3d8a | ||
|
|
293d89510a | ||
|
|
9446e88012 | ||
|
|
6f593beeed | ||
|
|
0dc20cd9b4 | ||
|
|
a0543e914e | ||
|
|
1435cd6526 | ||
|
|
7e24181c37 | ||
|
|
922c391ffc | ||
|
|
39169e8faa | ||
|
|
433712aa80 | ||
|
|
23650657cd | ||
|
|
b5d58b8a9e | ||
|
|
0514ff0189 | ||
|
|
9a15e3f9b3 | ||
|
|
104113852a | ||
|
|
430702abd3 | ||
|
|
d7300777cb | ||
|
|
4fd61a9c8d | ||
|
|
af2b4aa867 | ||
|
|
7e252f1692 | ||
|
|
a7e7174cb2 | ||
|
|
6e2d0c2aad | ||
|
|
aeb65d7cac | ||
|
|
e7c580d375 | ||
|
|
90fedade76 | ||
|
|
49d9715106 | ||
|
|
c194e8c59a | ||
|
|
b6f9315e2b | ||
|
|
f91f99de52 | ||
|
|
3ad3a769ab | ||
|
|
261bb5fa81 | ||
|
|
704dcf46d3 | ||
|
|
9fab50edb0 | ||
|
|
5d2a911849 | ||
|
|
89e96ee27a | ||
|
|
41636395ff | ||
|
|
6f1f89ac26 | ||
|
|
607eb4b4aa | ||
|
|
3078c076dc | ||
|
|
a7794fa2ad | ||
|
|
846b4e645c | ||
|
|
3775e99b02 | ||
|
|
cea77bddee | ||
|
|
8ac0d169d2 | ||
|
|
d5ac9f65f6 | ||
|
|
4b3f04c73f | ||
|
|
bb478c949a | ||
|
|
11b1003d4d | ||
|
|
c0ad5f2970 | ||
|
|
54c98cf3a1 | ||
|
|
dfbe8a2c0e | ||
|
|
873f80d534 | ||
|
|
089992db74 | ||
|
|
f07ab73fde | ||
|
|
166674bfe7 | ||
|
|
adb4a8fe01 | ||
|
|
c49e79dda3 | ||
|
|
a3b5e51356 | ||
|
|
8f91e23208 | ||
|
|
b768929cd8 | ||
|
|
49d5e5b953 | ||
|
|
ce4792e87b | ||
|
|
3ea0b1f36b | ||
|
|
51c7852b77 | ||
|
|
7947f10579 | ||
|
|
fca9297fa7 | ||
|
|
0ec5e3b365 | ||
|
|
c18937ecc7 | ||
|
|
8b962757b7 | ||
|
|
2b40e42965 | ||
|
|
0eac7816bc | ||
|
|
e3552d4086 | ||
|
|
75bb52ccca | ||
|
|
22c485d177 | ||
|
|
78dab5038c | ||
|
|
15cc02b083 | ||
|
|
419f2e90ce | ||
|
|
a29e3c23fe | ||
|
|
aa9ae4dd09 | ||
|
|
d02bf33345 | ||
|
|
0a1dc1724c | ||
|
|
80b866e135 | ||
|
|
e7030c734e | ||
|
|
e5458ee127 | ||
|
|
3f60cb3f7d | ||
|
|
8c800836d5 | ||
|
|
abfc146335 | ||
|
|
dd4ff03b08 | ||
|
|
be792cb40a | ||
|
|
cec5cf22de | ||
|
|
6ec5f3b98b | ||
|
|
0ac43fd3c7 | ||
|
|
a600f2f05b | ||
|
|
0c0a1c1dad | ||
|
|
c69df36b98 | ||
|
|
20ac9fbfbe | ||
|
|
b9756db115 | ||
|
|
5bfa36418b | ||
|
|
30c696adfe | ||
|
|
31887ab4b1 | ||
|
|
3678de09bf | ||
|
|
3f9172146d | ||
|
|
fc4480644a | ||
|
|
2062214a3b | ||
|
|
01487cfdf6 | ||
|
|
a2c913a5b2 | ||
|
|
84f5d1c879 | ||
|
|
48c289edf2 | ||
|
|
c9949581ef | ||
|
|
b4e3dc275d | ||
|
|
00f85836fa | ||
|
|
c4300332c9 | ||
|
|
10f8efc457 | ||
|
|
1b48eb8959 | ||
|
|
61d7374d95 | ||
|
|
baa48610ea | ||
|
|
ece8d0368b | ||
|
|
a9ffebb3ea | ||
|
|
b6c043aae9 | ||
|
|
d45d49edbd | ||
|
|
27f474b192 | ||
|
|
544119c49f | ||
|
|
800a66dc99 | ||
|
|
33de1c3618 | ||
|
|
6fec16d78a | ||
|
|
a5d6062aa8 | ||
|
|
de532f47fb | ||
|
|
60bcc802cf | ||
|
|
c143545ef9 | ||
|
|
0e8fdac6d6 | ||
|
|
45e6dd1561 | ||
|
|
23c37c9a81 | ||
|
|
098279ceb6 | ||
|
|
1fb791455e | ||
|
|
3339bbca50 | ||
|
|
ec77213ca6 | ||
|
|
de1c2c98d2 | ||
|
|
98247fa47a | ||
|
|
1eef95421a | ||
|
|
b8de563a45 | ||
|
|
fd5fbd779b | ||
|
|
cb07550388 | ||
|
|
a51632c0a3 | ||
|
|
9756bf6ac8 | ||
|
|
aaa96cff87 | ||
|
|
a50959d254 | ||
|
|
b1bd858df1 | ||
|
|
c2d6d9b1ac | ||
|
|
7288dd24e0 | ||
|
|
8f05ea581c | ||
|
|
03a0bc907b | ||
|
|
5ce4c8a055 | ||
|
|
b04181fed9 | ||
|
|
eee843bafd | ||
|
|
134fd0761d | ||
|
|
669481af06 | ||
|
|
b5640b3179 | ||
|
|
9abb305dbb | ||
|
|
0fd4791479 | ||
|
|
ce2ecdf44c | ||
|
|
949c0d3b76 | ||
|
|
316915842a | ||
|
|
1dd7dc36c3 | ||
|
|
fca763b814 | ||
|
|
9311125c72 | ||
|
|
3f1d4933c1 | ||
|
|
7fb23b5069 | ||
|
|
d74ad343f1 | ||
|
|
c0a8351e58 | ||
|
|
8e309e8658 | ||
|
|
3400a9f87a | ||
|
|
c6830059b2 | ||
|
|
7e4a18b365 | ||
|
|
9ecc8c14d8 | ||
|
|
a3c048b9c8 | ||
|
|
3c08054234 | ||
|
|
07e91d4eb1 | ||
|
|
c104498b43 | ||
|
|
91ba71ad23 | ||
|
|
5ae8914060 | ||
|
|
77c8f1244f | ||
|
|
5d5c8a0af7 | ||
|
|
dcaf3e6678 | ||
|
|
c0170a173c | ||
|
|
d182a7079d | ||
|
|
b5cc5653b2 | ||
|
|
bdbd908b3a | ||
|
|
11fedb1ffc | ||
|
|
7de82f6c0d | ||
|
|
782829c992 | ||
|
|
6ab76453d4 | ||
|
|
56767b92d7 | ||
|
|
621df40c66 | ||
|
|
ba7cb76640 | ||
|
|
d353853472 | ||
|
|
1fcf5f4709 | ||
|
|
0ec4630461 | ||
|
|
fa45dea1aa | ||
|
|
2217583052 | ||
|
|
f4dc7a133e | ||
|
|
26b1e64bad | ||
|
|
a1d8af6521 | ||
|
|
9fb3d093ff | ||
|
|
8c9b37a12f | ||
|
|
73e4596d1a | ||
|
|
83798e6823 | ||
|
|
6d9595b643 | ||
|
|
dc047d949d | ||
|
|
a31b4bc0a1 | ||
|
|
94b8633803 | ||
|
|
107e85033f | ||
|
|
eea8060182 | ||
|
|
83f7869de4 | ||
|
|
4f0eff8b88 | ||
|
|
58b438c345 | ||
|
|
bc57bb1a78 | ||
|
|
e08ab0dd33 | ||
|
|
64bfa246ae |
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
|
||||
@@ -17,10 +18,7 @@ def calendar(page: int = 1,
|
||||
"""
|
||||
浏览Bangumi每日放送
|
||||
"""
|
||||
medias = BangumiChain().calendar()
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||
return []
|
||||
return RecommendChain().bangumi_calendar(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson])
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.schemas import MediaType
|
||||
@@ -40,10 +41,7 @@ def movie_showing(page: int = 1,
|
||||
"""
|
||||
浏览豆瓣正在热映
|
||||
"""
|
||||
movies = DoubanChain().movie_showing(page=page, count=count)
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
return RecommendChain().douban_movie_showing(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
|
||||
@@ -55,11 +53,7 @@ def douban_movies(sort: str = "R",
|
||||
"""
|
||||
浏览豆瓣电影信息
|
||||
"""
|
||||
movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
return RecommendChain().douban_movies(sort=sort, tags=tags, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
|
||||
@@ -71,11 +65,7 @@ def douban_tvs(sort: str = "R",
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
return RecommendChain().douban_tvs(sort=sort, tags=tags, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
||||
@@ -85,10 +75,7 @@ def movie_top250(page: int = 1,
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
movies = DoubanChain().movie_top250(page=page, count=count)
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
return RecommendChain().douban_movie_top250(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
@@ -98,10 +85,7 @@ def tv_weekly_chinese(page: int = 1,
|
||||
"""
|
||||
中国每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
return RecommendChain().douban_tv_weekly_chinese(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
@@ -111,10 +95,7 @@ def tv_weekly_global(page: int = 1,
|
||||
"""
|
||||
全球每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
return RecommendChain().douban_tv_weekly_global(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
|
||||
@@ -124,10 +105,7 @@ def tv_animation(page: int = 1,
|
||||
"""
|
||||
热门动画剧集
|
||||
"""
|
||||
tvs = DoubanChain().tv_animation(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
return RecommendChain().douban_tv_animation(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
|
||||
@@ -137,10 +115,7 @@ def movie_hot(page: int = 1,
|
||||
"""
|
||||
热门电影
|
||||
"""
|
||||
movies = DoubanChain().movie_hot(page=page, count=count)
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
return RecommendChain().douban_movie_hot(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
|
||||
@@ -150,10 +125,7 @@ def tv_hot(page: int = 1,
|
||||
"""
|
||||
热门电视剧
|
||||
"""
|
||||
tvs = DoubanChain().tv_hot(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
return RecommendChain().douban_tv_hot(page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson])
|
||||
|
||||
@@ -17,7 +17,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
||||
def list(
|
||||
def current(
|
||||
name: str = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from typing import List, Any
|
||||
|
||||
import jieba
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.storage import StorageChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
@@ -39,7 +41,7 @@ def delete_download_history(history_in: schemas.DownloadHistory,
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/transfer", summary="查询转移历史记录", response_model=schemas.Response)
|
||||
@router.get("/transfer", summary="查询整理记录", response_model=schemas.Response)
|
||||
def transfer_history(title: str = None,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
@@ -47,7 +49,7 @@ def transfer_history(title: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询转移历史记录
|
||||
查询整理记录
|
||||
"""
|
||||
if title == "失败":
|
||||
title = None
|
||||
@@ -57,6 +59,9 @@ def transfer_history(title: str = None,
|
||||
status = True
|
||||
|
||||
if title:
|
||||
if settings.TOKENIZED_SEARCH:
|
||||
words = jieba.cut(title, HMM=False)
|
||||
title = "%".join(words)
|
||||
total = TransferHistory.count_by_title(db, title=title, status=status)
|
||||
result = TransferHistory.list_by_title(db, title=title, page=page,
|
||||
count=count, status=status)
|
||||
@@ -71,14 +76,14 @@ def transfer_history(title: str = None,
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/transfer", summary="删除转移历史记录", response_model=schemas.Response)
|
||||
@router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response)
|
||||
def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
deletesrc: bool = False,
|
||||
deletedest: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
删除转移历史记录
|
||||
删除整理记录
|
||||
"""
|
||||
history: TransferHistory = TransferHistory.get(db, history_in.id)
|
||||
if not history:
|
||||
@@ -86,9 +91,7 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
# 册除媒体库文件
|
||||
if deletedest and history.dest_fileitem:
|
||||
dest_fileitem = schemas.FileItem(**history.dest_fileitem)
|
||||
state = StorageChain().delete_media_file(fileitem=dest_fileitem, mtype=MediaType(history.type))
|
||||
if not state:
|
||||
return schemas.Response(success=False, message=f"{dest_fileitem.path} 删除失败")
|
||||
StorageChain().delete_media_file(fileitem=dest_fileitem, mtype=MediaType(history.type))
|
||||
|
||||
# 删除源文件
|
||||
if deletesrc and history.src_fileitem:
|
||||
@@ -109,11 +112,11 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/empty/transfer", summary="清空转移历史记录", response_model=schemas.Response)
|
||||
@router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response)
|
||||
def delete_transfer_history(db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
清空转移历史记录
|
||||
清空整理记录
|
||||
"""
|
||||
TransferHistory.truncate(db)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -117,7 +117,7 @@ def scrape(fileitem: schemas.FileItem,
|
||||
if not scrape_path.exists():
|
||||
return schemas.Response(success=False, message="刮削路径不存在")
|
||||
# 手动刮削
|
||||
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True)
|
||||
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Annotated, Any, List, Optional
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
|
||||
from app import schemas
|
||||
from app.chain.command import CommandChain
|
||||
from app.command import Command
|
||||
from app.core.config import settings
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_apikey, verify_token
|
||||
@@ -212,7 +212,7 @@ def install(plugin_id: str,
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
CommandChain().init_commands(plugin_id)
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
@@ -280,7 +280,7 @@ def reset_plugin(plugin_id: str,
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
CommandChain().init_commands(plugin_id)
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
@@ -308,7 +308,7 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
CommandChain().init_commands(plugin_id)
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -207,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="订阅不存在")
|
||||
@@ -273,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)
|
||||
|
||||
|
||||
@@ -506,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)
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import List, Any
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.security import verify_token
|
||||
from app.schemas.types import MediaType
|
||||
@@ -108,14 +109,10 @@ def tmdb_movies(sort_by: str = "popularity.desc",
|
||||
"""
|
||||
浏览TMDB电影信息
|
||||
"""
|
||||
movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
if not movies:
|
||||
return []
|
||||
return [movie.to_dict() for movie in movies]
|
||||
return RecommendChain().tmdb_movies(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
|
||||
|
||||
@router.get("/tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
|
||||
@@ -127,26 +124,19 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
if not tvs:
|
||||
return []
|
||||
return [tv.to_dict() for tv in tvs]
|
||||
return RecommendChain().tmdb_tvs(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
|
||||
|
||||
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_trending(page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
TMDB流行趋势
|
||||
"""
|
||||
infos = TmdbChain().tmdb_trending(page=page)
|
||||
if not infos:
|
||||
return []
|
||||
return [info.to_dict() for info in infos]
|
||||
return RecommendChain().tmdb_trending(page=page)
|
||||
|
||||
|
||||
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
@@ -14,50 +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
|
||||
# 日志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
|
||||
|
||||
|
||||
@router.get("/name", summary="查询整理后的名称", response_model=schemas.Response)
|
||||
def query_name(path: str, filetype: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
@@ -87,13 +47,35 @@ def query_name(path: str, filetype: str,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/queue", summary="查询整理队列", response_model=List[schemas.TransferJob])
|
||||
def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询整理队列
|
||||
:param _: Token校验
|
||||
"""
|
||||
return TransferChain().get_queue_tasks()
|
||||
|
||||
|
||||
@router.delete("/queue", summary="从整理队列中删除任务", response_model=schemas.Response)
|
||||
def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询整理队列
|
||||
:param fileitem: 文件项
|
||||
:param _: Token校验
|
||||
"""
|
||||
TransferChain().remove_from_queue(fileitem)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
||||
def manual_transfer(transer_item: ManualTransferItem,
|
||||
background: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
手动转移,文件或历史记录,支持自定义剧集识别格式
|
||||
:param transer_item: 手工整理项
|
||||
:param background: 后台运行
|
||||
:param db: 数据库
|
||||
:param _: Token校验
|
||||
"""
|
||||
@@ -103,7 +85,7 @@ def manual_transfer(transer_item: ManualTransferItem,
|
||||
# 查询历史记录
|
||||
history: TransferHistory = TransferHistory.get(db, transer_item.logid)
|
||||
if not history:
|
||||
return schemas.Response(success=False, message=f"历史记录不存在,ID:{transer_item.logid}")
|
||||
return schemas.Response(success=False, message=f"整理记录不存在,ID:{transer_item.logid}")
|
||||
# 强制转移
|
||||
force = True
|
||||
if history.status and ("move" in history.mode):
|
||||
@@ -170,7 +152,8 @@ def manual_transfer(transer_item: ManualTransferItem,
|
||||
scrape=transer_item.scrape,
|
||||
library_type_folder=transer_item.library_type_folder,
|
||||
library_category_folder=transer_item.library_category_folder,
|
||||
force=force
|
||||
force=force,
|
||||
background=background
|
||||
)
|
||||
# 失败
|
||||
if not state:
|
||||
|
||||
@@ -19,7 +19,7 @@ class GzipRequest(Request):
|
||||
body = await super().body()
|
||||
if "gzip" in self.headers.getlist("Content-Encoding"):
|
||||
body = gzip.decompress(body)
|
||||
self._body = body
|
||||
self._body = body # noqa
|
||||
return self._body
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import gc
|
||||
import pickle
|
||||
import traceback
|
||||
@@ -61,7 +62,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
try:
|
||||
with open(settings.TEMP_PATH / filename, 'wb') as f:
|
||||
pickle.dump(cache, f)
|
||||
pickle.dump(cache, f) # noqa
|
||||
except Exception as err:
|
||||
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
|
||||
finally:
|
||||
@@ -342,7 +343,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None,
|
||||
downloader: str = None
|
||||
) -> Optional[Tuple[Optional[str], Optional[str], str]]:
|
||||
) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
:param content: 种子文件地址或者磁力链接
|
||||
@@ -351,7 +352,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param episodes: 需要下载的集数
|
||||
:param category: 种子分类
|
||||
:param downloader: 下载器
|
||||
:return: 下载器名称、种子Hash、错误信息
|
||||
:return: 下载器名称、种子Hash、种子文件布局、错误原因
|
||||
"""
|
||||
return self.run_module("download", content=content, download_dir=download_dir,
|
||||
cookie=cookie, episodes=episodes, category=category,
|
||||
@@ -488,32 +489,58 @@ class ChainBase(metaclass=ABCMeta):
|
||||
f"title={message.title}, "
|
||||
f"text={message.text},"
|
||||
f"userid={message.userid}")
|
||||
if not message.userid and message.mtype:
|
||||
# 没有指定用户ID时,按规则确定发送对象
|
||||
# 默认发送全体
|
||||
to_targets = None
|
||||
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
|
||||
if notify_action == "admin":
|
||||
# 仅发送管理员
|
||||
logger.info(f"已设置 {message.mtype} 的消息只发送给管理员")
|
||||
to_targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
elif notify_action == "user":
|
||||
# 发送对应用户
|
||||
if message.username:
|
||||
logger.info(f"已设置 {message.mtype} 的消息只发送给用户 {message.username}")
|
||||
to_targets = self.useroper.get_settings(message.username)
|
||||
if not message.username or to_targets is None:
|
||||
if message.username:
|
||||
logger.info(f"没有 {message.username} 这个用户,该消息将发送给管理员")
|
||||
# 回滚发送管理员
|
||||
to_targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
message.targets = to_targets
|
||||
# 发送事件
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
|
||||
# 保存消息
|
||||
# 保存原消息
|
||||
self.messagehelper.put(message, role="user", title=message.title)
|
||||
self.messageoper.add(**message.dict())
|
||||
# 发送
|
||||
# 发送消息按设置隔离
|
||||
if not message.userid and message.mtype:
|
||||
# 消息隔离设置
|
||||
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
|
||||
if notify_action:
|
||||
# 'admin' 'user,admin' 'user' 'all'
|
||||
actions = notify_action.split(",")
|
||||
# 是否已发送管理员标志
|
||||
admin_sended = False
|
||||
send_orignal = False
|
||||
for action in actions:
|
||||
send_message = copy.deepcopy(message)
|
||||
if action == "admin" and not admin_sended:
|
||||
# 仅发送管理员
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
elif action == "user" and send_message.username:
|
||||
# 发送对应用户
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}")
|
||||
# 读取用户消息IDS
|
||||
send_message.targets = self.useroper.get_settings(send_message.username)
|
||||
if send_message.targets is None:
|
||||
# 没有找到用户
|
||||
if not admin_sended:
|
||||
# 回滚发送管理员
|
||||
logger.info(f"用户 {send_message.username} 不存在,消息将发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
else:
|
||||
# 管理员发过了,此消息不发了
|
||||
logger.info(f"用户 {send_message.username} 不存在,消息无法发送到对应用户")
|
||||
continue
|
||||
else:
|
||||
# 按原消息发送全体
|
||||
if not admin_sended:
|
||||
send_orignal = True
|
||||
break
|
||||
# 按设定发送
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage,
|
||||
data={**send_message.dict(), "type": send_message.mtype})
|
||||
self.run_module("post_message", message=send_message)
|
||||
if not send_orignal:
|
||||
return
|
||||
# 发送消息事件
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
|
||||
# 按原消息发送
|
||||
self.run_module("post_message", message=message)
|
||||
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||
|
||||
@@ -19,8 +19,7 @@ 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
|
||||
from app.schemas.event import ResourceSelectionEventData, ResourceDownloadEventData
|
||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification, 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
|
||||
@@ -227,7 +226,7 @@ class DownloadChain(ChainBase):
|
||||
# 发送资源下载事件,允许外部拦截下载
|
||||
event_data = ResourceDownloadEventData(
|
||||
context=context,
|
||||
episodes=episodes,
|
||||
episodes=episodes or context.meta_info.episode_list,
|
||||
channel=channel,
|
||||
origin=source,
|
||||
downloader=downloader,
|
||||
@@ -313,16 +312,23 @@ class DownloadChain(ChainBase):
|
||||
category=_media.category,
|
||||
downloader=downloader or _site_downloader)
|
||||
if result:
|
||||
_downloader, _hash, error_msg = result
|
||||
_downloader, _hash, _layout, error_msg = result
|
||||
else:
|
||||
_downloader, _hash, error_msg = None, None, "未找到下载器"
|
||||
_downloader, _hash, _layout, error_msg = None, None, None, "未找到下载器"
|
||||
|
||||
if _hash:
|
||||
# 下载文件路径
|
||||
if _folder_name:
|
||||
download_path = download_dir / _folder_name
|
||||
else:
|
||||
# `不创建子文件夹` 或 `不存在子文件夹`
|
||||
if _layout == "NoSubfolder" or not _folder_name:
|
||||
# 下载路径记录至文件
|
||||
download_path = download_dir / _file_list[0] if _file_list else download_dir
|
||||
# 原始布局
|
||||
elif _folder_name:
|
||||
download_path = download_dir / _folder_name
|
||||
# 创建子文件夹
|
||||
else:
|
||||
download_path = download_dir / Path(_file_list[0]).stem if _file_list else download_dir
|
||||
# 文件保存路径
|
||||
_save_path = download_dir if _layout == "NoSubfolder" or not _folder_name else download_path
|
||||
|
||||
# 登记下载记录
|
||||
self.downloadhis.add(
|
||||
@@ -337,6 +343,7 @@ class DownloadChain(ChainBase):
|
||||
seasons=_meta.season,
|
||||
episodes=download_episodes or _meta.episode,
|
||||
image=_media.get_backdrop_image(),
|
||||
downloader=_downloader,
|
||||
download_hash=_hash,
|
||||
torrent_name=_torrent.title,
|
||||
torrent_description=_torrent.description,
|
||||
@@ -345,7 +352,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}
|
||||
)
|
||||
|
||||
# 登记下载文件
|
||||
@@ -364,8 +372,8 @@ class DownloadChain(ChainBase):
|
||||
files_to_add.append({
|
||||
"download_hash": _hash,
|
||||
"downloader": _downloader,
|
||||
"fullpath": str(download_dir / _folder_name / file),
|
||||
"savepath": str(download_dir / _folder_name),
|
||||
"fullpath": str(_save_path / file),
|
||||
"savepath": str(_save_path),
|
||||
"filepath": file,
|
||||
"torrentname": _meta.org_string,
|
||||
})
|
||||
@@ -383,7 +391,8 @@ class DownloadChain(ChainBase):
|
||||
"context": context,
|
||||
"username": username,
|
||||
"downloader": _downloader,
|
||||
"episodes": episodes
|
||||
"episodes": episodes or _meta.episode_list,
|
||||
"source": source
|
||||
})
|
||||
else:
|
||||
# 下载失败
|
||||
@@ -489,7 +498,8 @@ class DownloadChain(ChainBase):
|
||||
logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}")
|
||||
event_data = ResourceSelectionEventData(
|
||||
contexts=contexts,
|
||||
downloader=downloader
|
||||
downloader=downloader,
|
||||
origin=source
|
||||
)
|
||||
event = eventmanager.send_event(ChainEventType.ResourceSelection, event_data)
|
||||
# 如果事件修改了上下文数据,使用更新后的数据
|
||||
@@ -517,8 +527,8 @@ class DownloadChain(ChainBase):
|
||||
downloaded_list.append(context)
|
||||
|
||||
# 电视剧整季匹配
|
||||
logger.info(f"开始匹配电视剧整季:{no_exists}")
|
||||
if no_exists:
|
||||
logger.info(f"开始匹配电视剧整季:{no_exists}")
|
||||
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
|
||||
need_seasons: Dict[int, list] = {}
|
||||
for need_mid, need_tv in no_exists.items():
|
||||
@@ -621,8 +631,8 @@ class DownloadChain(ChainBase):
|
||||
# 全部下载完成
|
||||
break
|
||||
# 电视剧季内的集匹配
|
||||
logger.info(f"开始电视剧完整集匹配:{no_exists}")
|
||||
if no_exists:
|
||||
logger.info(f"开始电视剧完整集匹配:{no_exists}")
|
||||
# TMDBID列表
|
||||
need_tv_list = list(no_exists)
|
||||
for need_mid in need_tv_list:
|
||||
@@ -691,8 +701,8 @@ class DownloadChain(ChainBase):
|
||||
logger.info(f"季 {need_season} 剩余需要集:{need_episodes}")
|
||||
|
||||
# 仍然缺失的剧集,从整季中选择需要的集数文件下载,仅支持QB和TR
|
||||
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
|
||||
if no_exists:
|
||||
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
|
||||
# TMDBID列表
|
||||
no_exists_list = list(no_exists)
|
||||
for need_mid in no_exists_list:
|
||||
|
||||
@@ -307,6 +307,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
fileitem: FileItem = event_data.get("fileitem")
|
||||
meta: MetaBase = event_data.get("meta")
|
||||
mediainfo: MediaInfo = event_data.get("mediainfo")
|
||||
overwrite = event_data.get("overwrite", False)
|
||||
if not fileitem:
|
||||
return
|
||||
# 刮削锁
|
||||
@@ -316,7 +317,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
scraping_files.append(fileitem.path)
|
||||
try:
|
||||
# 执行刮削
|
||||
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=overwrite)
|
||||
finally:
|
||||
# 释放锁
|
||||
with scraping_lock:
|
||||
@@ -365,8 +366,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
if not _fileitem or not _content or not _path:
|
||||
return
|
||||
# 保存文件到临时目录
|
||||
tmp_file = settings.TEMP_PATH / _path.name
|
||||
# 保存文件到临时目录,文件名随机
|
||||
tmp_file = settings.TEMP_PATH / f"{_path.name}.{StringUtils.generate_random_str(10)}"
|
||||
tmp_file.write_bytes(_content)
|
||||
# 获取文件的父目录
|
||||
try:
|
||||
@@ -412,31 +413,31 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if fileitem.type == "file":
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 电影文件
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if movie_nfo:
|
||||
# 保存或上传nfo文件到上级目录
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 电影文件
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if not movie_nfo:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
return
|
||||
# 保存或上传nfo文件到上级目录
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
# 电影目录
|
||||
if is_bluray_folder(fileitem):
|
||||
# 原盘目录
|
||||
nfo_path = filepath / (filepath.name + ".nfo")
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 生成原盘nfo
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if movie_nfo:
|
||||
# 保存或上传nfo文件到当前目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 生成原盘nfo
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if not movie_nfo:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
return
|
||||
# 保存或上传nfo文件到当前目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
# 处理目录内的文件
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
@@ -455,23 +456,18 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
and attr_value.startswith("http"):
|
||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||
image_path = filepath / image_name
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(_url=attr_value)
|
||||
# 写入图片到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
continue
|
||||
# 下载图片
|
||||
content = __download_image(_url=attr_value)
|
||||
# 写入图片到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
else:
|
||||
# 电视剧
|
||||
if fileitem.type == "file":
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 重新识别季集
|
||||
file_meta = MetaInfoPath(filepath)
|
||||
if not file_meta.begin_episode:
|
||||
@@ -481,33 +477,37 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
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文件到上级目录
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 获取集的nfo文件
|
||||
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||
if episode_nfo:
|
||||
# 保存或上传nfo文件到上级目录
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
# 获取集的图片
|
||||
image_dict = self.metadata_img(mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||
if image_dict:
|
||||
for episode, image_url in image_dict.items():
|
||||
image_path = filepath.with_suffix(Path(image_url).suffix)
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
|
||||
# 下载图片
|
||||
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)
|
||||
else:
|
||||
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)
|
||||
|
||||
else:
|
||||
# 当前为目录,处理目录内的文件
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
@@ -526,32 +526,33 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if season_meta.begin_season is not None:
|
||||
# 是否已存在
|
||||
nfo_path = filepath / "season.nfo"
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 当前目录有季号,生成季nfo
|
||||
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo,
|
||||
season=season_meta.begin_season)
|
||||
if season_nfo:
|
||||
# 写入nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
|
||||
else:
|
||||
logger.warn(f"无法生成电视剧季nfo文件:{meta.name}")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 当前目录有季号,生成季nfo
|
||||
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo, season=season_meta.begin_season)
|
||||
if not season_nfo:
|
||||
logger.warn(f"无法生成电视剧季nfo文件:{meta.name}")
|
||||
return
|
||||
# 写入nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _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)
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
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)
|
||||
else:
|
||||
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)
|
||||
# 额外fanart季图片:poster thumb banner
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||
if image_dict:
|
||||
@@ -563,32 +564,31 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
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):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
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)
|
||||
else:
|
||||
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:
|
||||
# 是否已存在
|
||||
nfo_path = filepath / "tvshow.nfo"
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
||||
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if tv_nfo:
|
||||
# 写入tvshow nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
|
||||
else:
|
||||
logger.warn(f"无法生成电视剧nfo文件:{meta.name}")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 当前目录有名称,生成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到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
|
||||
# 生成目录图片
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||
if image_dict:
|
||||
@@ -597,14 +597,13 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
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):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
continue
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
|
||||
logger.info(f"{filepath.name} 刮削完成")
|
||||
|
||||
323
app/chain/recommend.py
Normal file
323
app/chain/recommend.py
Normal file
@@ -0,0 +1,323 @@
|
||||
import inspect
|
||||
import io
|
||||
import tempfile
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List
|
||||
|
||||
from PIL import Image
|
||||
from cachetools import TTLCache
|
||||
from cachetools.keys import hashkey
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.utils.common import log_execution_time
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.security import SecurityUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
# 推荐相关的专用缓存
|
||||
recommend_ttl = 24 * 3600
|
||||
recommend_cache = TTLCache(maxsize=256, ttl=recommend_ttl)
|
||||
|
||||
|
||||
# 推荐缓存装饰器,避免偶发网络获取数据为空时,页面由于缓存为空长时间渲染异常问题
|
||||
def cached_with_empty_check(func: Callable):
|
||||
"""
|
||||
缓存装饰器,用于缓存函数的返回结果,仅在结果非空时进行缓存
|
||||
|
||||
:param func: 被装饰的函数
|
||||
:return: 包装后的函数
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
signature = inspect.signature(func)
|
||||
resolved_kwargs = {}
|
||||
# 获取默认值并结合传递的参数(如果有)
|
||||
for param, value in signature.parameters.items():
|
||||
if param in kwargs:
|
||||
# 使用显式传递的参数
|
||||
resolved_kwargs[param] = kwargs[param]
|
||||
elif value.default is not inspect.Parameter.empty:
|
||||
# 没有传递参数时使用默认值
|
||||
resolved_kwargs[param] = value.default
|
||||
# 使用 cachetools 缓存,构造缓存键
|
||||
cache_key = f"{func.__name__}_{hashkey(*args, **resolved_kwargs)}"
|
||||
if cache_key in recommend_cache:
|
||||
return recommend_cache[cache_key]
|
||||
result = func(*args, **kwargs)
|
||||
# 如果返回值为空,则不缓存
|
||||
if result in [None, [], {}]:
|
||||
return result
|
||||
recommend_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
推荐处理链,单例运行
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.doubanchain = DoubanChain()
|
||||
self.bangumichain = BangumiChain()
|
||||
self.cache_max_pages = 5
|
||||
|
||||
def refresh_recommend(self):
|
||||
"""
|
||||
刷新推荐
|
||||
"""
|
||||
logger.debug("Starting to refresh Recommend data.")
|
||||
recommend_cache.clear()
|
||||
logger.debug("Recommend Cache has been cleared.")
|
||||
|
||||
# 推荐来源方法
|
||||
recommend_methods = [
|
||||
self.tmdb_movies,
|
||||
self.tmdb_tvs,
|
||||
self.tmdb_trending,
|
||||
self.bangumi_calendar,
|
||||
self.douban_movie_showing,
|
||||
self.douban_movies,
|
||||
self.douban_tvs,
|
||||
self.douban_movie_top250,
|
||||
self.douban_tv_weekly_chinese,
|
||||
self.douban_tv_weekly_global,
|
||||
self.douban_tv_animation,
|
||||
self.douban_movie_hot,
|
||||
self.douban_tv_hot,
|
||||
]
|
||||
|
||||
# 缓存并刷新所有推荐数据
|
||||
recommends = []
|
||||
# 记录哪些方法已完成
|
||||
methods_finished = set()
|
||||
# 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历
|
||||
for page in range(1, self.cache_max_pages + 1):
|
||||
for method in recommend_methods:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
if method in methods_finished:
|
||||
continue
|
||||
logger.debug(f"Fetch {method.__name__} data for page {page}.")
|
||||
data = method(page=page)
|
||||
if not data:
|
||||
logger.debug("All recommendation methods have finished fetching data. Ending pagination early.")
|
||||
methods_finished.add(method)
|
||||
continue
|
||||
recommends.extend(data)
|
||||
# 如果所有方法都已经完成,提前结束循环
|
||||
if len(methods_finished) == len(recommend_methods):
|
||||
break
|
||||
|
||||
# 缓存收集到的海报
|
||||
self.__cache_posters(recommends)
|
||||
logger.debug("Recommend data refresh completed.")
|
||||
|
||||
def __cache_posters(self, datas: List[dict]):
|
||||
"""
|
||||
提取 poster_path 并缓存图片
|
||||
:param datas: 数据列表
|
||||
"""
|
||||
if not settings.GLOBAL_IMAGE_CACHE:
|
||||
return
|
||||
|
||||
for data in datas:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
poster_path = data.get("poster_path")
|
||||
if poster_path:
|
||||
poster_url = poster_path.replace("original", "w500")
|
||||
logger.debug(f"Caching poster image: {poster_url}")
|
||||
self.__fetch_and_save_image(poster_url)
|
||||
|
||||
@staticmethod
|
||||
def __fetch_and_save_image(url: str):
|
||||
"""
|
||||
请求并保存图片
|
||||
:param url: 图片路径
|
||||
"""
|
||||
if not settings.GLOBAL_IMAGE_CACHE or not url:
|
||||
return
|
||||
|
||||
# 生成缓存路径
|
||||
sanitized_path = SecurityUtils.sanitize_url_path(url)
|
||||
cache_path = settings.CACHE_PATH / "images" / sanitized_path
|
||||
|
||||
# 确保缓存路径和文件类型合法
|
||||
if not SecurityUtils.is_safe_path(settings.CACHE_PATH, cache_path, settings.SECURITY_IMAGE_SUFFIXES):
|
||||
logger.debug(f"Invalid cache path or file type for URL: {url}, sanitized path: {sanitized_path}")
|
||||
return
|
||||
|
||||
# 本地存在缓存图片,则直接跳过
|
||||
if cache_path.exists():
|
||||
logger.debug(f"Cache hit: Image already exists at {cache_path}")
|
||||
return
|
||||
|
||||
# 请求远程图片
|
||||
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
|
||||
proxies = settings.PROXY if not referer else None
|
||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=proxies, referer=referer).get_res(url=url)
|
||||
if not response:
|
||||
logger.debug(f"Empty response for URL: {url}")
|
||||
return
|
||||
|
||||
# 验证下载的内容是否为有效图片
|
||||
try:
|
||||
Image.open(io.BytesIO(response.content)).verify()
|
||||
except Exception as e:
|
||||
logger.debug(f"Invalid image format for URL {url}: {e}")
|
||||
return
|
||||
|
||||
if not cache_path:
|
||||
return
|
||||
|
||||
try:
|
||||
if not cache_path.parent.exists():
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file:
|
||||
tmp_file.write(response.content)
|
||||
temp_path = Path(tmp_file.name)
|
||||
temp_path.replace(cache_path)
|
||||
logger.debug(f"Successfully cached image at {cache_path} for URL: {url}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to write cache file {cache_path} for URL {url}: {e}")
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def tmdb_movies(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
||||
with_original_language: str = "", page: int = 1) -> Any:
|
||||
"""
|
||||
TMDB热门电影
|
||||
"""
|
||||
movies = self.tmdbchain.tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
return [movie.to_dict() for movie in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def tmdb_tvs(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
||||
with_original_language: str = "zh|en|ja|ko", page: int = 1) -> Any:
|
||||
"""
|
||||
TMDB热门电视剧
|
||||
"""
|
||||
tvs = self.tmdbchain.tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
return [tv.to_dict() for tv in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def tmdb_trending(self, page: int = 1) -> Any:
|
||||
"""
|
||||
TMDB流行趋势
|
||||
"""
|
||||
infos = self.tmdbchain.tmdb_trending(page=page)
|
||||
return [info.to_dict() for info in infos] if infos else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def bangumi_calendar(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
Bangumi每日放送
|
||||
"""
|
||||
medias = self.bangumichain.calendar()
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movie_showing(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣正在热映
|
||||
"""
|
||||
movies = self.doubanchain.movie_showing(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movies(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣最新电影
|
||||
"""
|
||||
movies = self.doubanchain.douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tvs(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣最新电视剧
|
||||
"""
|
||||
tvs = self.doubanchain.douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movie_top250(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣电影TOP250
|
||||
"""
|
||||
movies = self.doubanchain.movie_top250(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣国产剧集榜
|
||||
"""
|
||||
tvs = self.doubanchain.tv_weekly_chinese(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_weekly_global(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣全球剧集榜
|
||||
"""
|
||||
tvs = self.doubanchain.tv_weekly_global(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_animation(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣热门动漫
|
||||
"""
|
||||
tvs = self.doubanchain.tv_animation(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_movie_hot(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣热门电影
|
||||
"""
|
||||
movies = self.doubanchain.movie_hot(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached_with_empty_check
|
||||
def douban_tv_hot(self, page: int = 1, count: int = 30) -> Any:
|
||||
"""
|
||||
豆瓣热门电视剧
|
||||
"""
|
||||
tvs = self.doubanchain.tv_hot(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
@@ -1,6 +1,7 @@
|
||||
import base64
|
||||
import re
|
||||
from datetime import datetime
|
||||
from time import time
|
||||
from typing import Optional, Tuple, Union, Dict
|
||||
from urllib.parse import urljoin
|
||||
|
||||
@@ -87,7 +88,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 or "", re.IGNORECASE)):
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【站点分享率低预警】",
|
||||
@@ -95,7 +97,7 @@ class SiteChain(ChainBase):
|
||||
))
|
||||
return userdata
|
||||
|
||||
def refresh_userdatas(self) -> Dict[str, SiteUserData]:
|
||||
def refresh_userdatas(self) -> Optional[Dict[str, SiteUserData]]:
|
||||
"""
|
||||
刷新所有站点的用户数据
|
||||
"""
|
||||
@@ -104,7 +106,7 @@ class SiteChain(ChainBase):
|
||||
result = {}
|
||||
for site in sites:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
return None
|
||||
if site.get("is_active"):
|
||||
userdata = self.refresh_userdata(site)
|
||||
if userdata:
|
||||
@@ -171,27 +173,37 @@ class SiteChain(ChainBase):
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": user_agent,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Authorization": site.token
|
||||
"Authorization": site.token,
|
||||
"x-api-key": site.apikey,
|
||||
"ts": str(int(time()))
|
||||
}
|
||||
res = RequestUtils(
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=site.timeout or 15
|
||||
).post_res(url=url)
|
||||
state = False
|
||||
message = "鉴权已过期或无效"
|
||||
if res and res.status_code == 200:
|
||||
user_info = res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
user_info = res.json() or {}
|
||||
if user_info.get("data"):
|
||||
# 更新最后访问时间
|
||||
del headers["x-api-key"]
|
||||
res = RequestUtils(headers=headers,
|
||||
timeout=site.timeout or 15,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
referer=f"{site.url}index"
|
||||
).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse")
|
||||
if res:
|
||||
return True, "连接成功"
|
||||
else:
|
||||
return True, f"连接成功,但更新状态失败"
|
||||
return False, "鉴权已过期或无效"
|
||||
state = True
|
||||
message = "连接成功,但更新状态失败"
|
||||
if res and res.status_code == 200:
|
||||
update_info = res.json() or {}
|
||||
if "code" in update_info and int(update_info["code"]) == 0:
|
||||
message = "连接成功"
|
||||
elif user_info.get("message"):
|
||||
# 使用馒头的错误提示
|
||||
message = user_info.get("message")
|
||||
return state, message
|
||||
|
||||
@staticmethod
|
||||
def __yema_test(site: Site) -> Tuple[bool, str]:
|
||||
@@ -318,6 +330,7 @@ class SiteChain(ChainBase):
|
||||
continue
|
||||
# 新增站点
|
||||
domain_url = __indexer_domain(inx=indexer, sub_domain=domain)
|
||||
proxy = False
|
||||
res = RequestUtils(cookies=cookie,
|
||||
ua=settings.USER_AGENT
|
||||
).get_res(url=domain_url)
|
||||
@@ -335,16 +348,37 @@ class SiteChain(ChainBase):
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接状态码:{res.status_code},无法添加站点")
|
||||
continue
|
||||
else:
|
||||
_fail_count += 1
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
|
||||
continue
|
||||
if not settings.PROXY_HOST:
|
||||
_fail_count += 1
|
||||
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
|
||||
continue
|
||||
else:
|
||||
# 如果配置了代理,尝试通过代理重试
|
||||
logger.info(f"站点 {indexer.get('name')} 初次连接失败,尝试通过代理重试...")
|
||||
proxy = True
|
||||
res = RequestUtils(cookies=cookie,
|
||||
ua=settings.USER_AGENT,
|
||||
proxies=settings.PROXY
|
||||
).get_res(url=domain_url)
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
if not indexer.get("public") and not SiteUtils.is_logged_in(res.text):
|
||||
logger.warn(f"站点 {indexer.get('name')} 登录失败,即使通过代理,无法添加站点")
|
||||
_fail_count += 1
|
||||
continue
|
||||
logger.info(f"站点 {indexer.get('name')} 通过代理连接成功")
|
||||
else:
|
||||
logger.warn(f"站点 {indexer.get('name')} 通过代理连接失败,无法添加站点")
|
||||
_fail_count += 1
|
||||
continue
|
||||
|
||||
# 获取rss地址
|
||||
rss_url = None
|
||||
if not indexer.get("public") and domain_url:
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(url=domain_url,
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT)
|
||||
ua=settings.USER_AGENT,
|
||||
proxy=proxy)
|
||||
if errmsg:
|
||||
logger.warn(errmsg)
|
||||
# 插入数据库
|
||||
@@ -354,6 +388,7 @@ class SiteChain(ChainBase):
|
||||
domain=domain,
|
||||
cookie=cookie,
|
||||
rss=rss_url,
|
||||
proxy=1 if proxy else 0,
|
||||
public=1 if indexer.get("public") else 0)
|
||||
_add_count += 1
|
||||
|
||||
|
||||
@@ -84,6 +84,12 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("rename_file", fileitem=fileitem, name=name)
|
||||
|
||||
def get_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
查询目录或文件
|
||||
"""
|
||||
return self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path))
|
||||
|
||||
def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
根据路径获取文件项
|
||||
@@ -108,7 +114,7 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("storage_usage", storage=storage)
|
||||
|
||||
def support_transtype(self, storage: str) -> Optional[str]:
|
||||
def support_transtype(self, storage: str) -> Optional[dict]:
|
||||
"""
|
||||
获取支持的整理方式
|
||||
"""
|
||||
@@ -125,6 +131,12 @@ class StorageChain(ChainBase):
|
||||
return False
|
||||
if fileitem.type == "dir":
|
||||
# 本身是目录
|
||||
if _blue_dir := self.list_files(fileitem=fileitem, recursion=False):
|
||||
# 删除蓝光目录
|
||||
for _f in _blue_dir:
|
||||
if _f.type == "dir" and _f.name in ["BDMV", "CERTIFICATE"]:
|
||||
logger.warn(f"【{fileitem.storage}】{_f.path} 删除蓝光目录")
|
||||
self.delete_file(_f)
|
||||
if self.any_files(fileitem, extensions=media_exts) is False:
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 不存在其它媒体文件,删除空目录")
|
||||
return self.delete_file(fileitem)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import copy
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Union, Tuple
|
||||
|
||||
@@ -287,55 +288,13 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
continue
|
||||
|
||||
# 非洗版状态
|
||||
if not subscribe.best_version:
|
||||
# 每季总集数
|
||||
totals = {}
|
||||
if subscribe.season and subscribe.total_episode:
|
||||
totals = {
|
||||
subscribe.season: subscribe.total_episode
|
||||
}
|
||||
# 查询媒体库缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
totals=totals
|
||||
)
|
||||
else:
|
||||
# 洗版状态
|
||||
exist_flag = False
|
||||
if meta.type == MediaType.TV:
|
||||
no_exists = {
|
||||
mediakey: {
|
||||
subscribe.season: NotExistMediaInfo(
|
||||
season=subscribe.season,
|
||||
episodes=[],
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode or 1)
|
||||
}
|
||||
}
|
||||
else:
|
||||
no_exists = {}
|
||||
|
||||
# 已存在
|
||||
# 如果媒体已存在或已下载完毕,跳过当前订阅处理
|
||||
exist_flag, no_exists = self.check_and_handle_existing_media(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
mediakey=mediakey)
|
||||
if exist_flag:
|
||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)
|
||||
continue
|
||||
|
||||
# 电视剧订阅处理缺失集
|
||||
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)
|
||||
)
|
||||
|
||||
# 站点范围
|
||||
sites = self.get_sub_sites(subscribe)
|
||||
|
||||
@@ -399,15 +358,19 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
save_path=subscribe.save_path,
|
||||
media_category=subscribe.media_category,
|
||||
downloader=subscribe.downloader,
|
||||
source="Subscribe"
|
||||
source=self.get_subscribe_source_keyword(subscribe)
|
||||
)
|
||||
|
||||
# 同步外部修改,更新订阅信息
|
||||
subscribe = self.subscribeoper.get(subscribe.id)
|
||||
|
||||
# 判断是否应完成订阅
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||
downloads=downloads, lefts=lefts)
|
||||
if subscribe:
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||
downloads=downloads, lefts=lefts)
|
||||
finally:
|
||||
# 如果状态为N则更新为R
|
||||
if subscribe.state == 'N':
|
||||
if subscribe and subscribe.state == 'N':
|
||||
self.subscribeoper.update(subscribe.id, {'state': 'R'})
|
||||
|
||||
# 手动触发时发送系统消息
|
||||
@@ -432,15 +395,17 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
return
|
||||
# 当前下载资源的优先级
|
||||
priority = max([item.torrent_info.pri_order for item in downloads])
|
||||
# 订阅存在待定策略,不管是否已完成,均需更新订阅信息
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"current_priority": priority,
|
||||
"last_update": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
if priority == 100:
|
||||
# 洗版完成
|
||||
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo, bestversion=True)
|
||||
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
else:
|
||||
# 正在洗版,更新资源优先级
|
||||
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级为 {priority}')
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"current_priority": priority
|
||||
})
|
||||
|
||||
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaInfo, mediainfo: MediaInfo,
|
||||
downloads: List[Context] = None,
|
||||
@@ -454,29 +419,27 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
no_lefts = not lefts or not lefts.get(mediakey)
|
||||
# 是否完成订阅
|
||||
if not subscribe.best_version:
|
||||
# 非洗板
|
||||
# 订阅存在待定策略,不管是否已完成,均需更新订阅信息
|
||||
# 更新订阅已下载信息
|
||||
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
|
||||
# 更新订阅剩余集数和时间
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, mediainfo=mediainfo,
|
||||
update_date=bool(downloads))
|
||||
# 判断是否需要完成订阅
|
||||
if ((no_lefts and meta.type == MediaType.TV)
|
||||
or (downloads and meta.type == MediaType.MOVIE)
|
||||
or force):
|
||||
# 完成订阅
|
||||
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
elif downloads and meta.type == MediaType.TV:
|
||||
# 电视剧更新已下载集数
|
||||
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
|
||||
# 更新订阅剩余集数和时间
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
mediainfo=mediainfo, update_date=True)
|
||||
else:
|
||||
# 未下载到内容且不完整
|
||||
logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...')
|
||||
if meta.type == MediaType.TV:
|
||||
# 更新订阅剩余集数
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
mediainfo=mediainfo, update_date=False)
|
||||
elif downloads:
|
||||
# 洗板,下载到了内容,更新资源优先级
|
||||
# 洗版下载到了内容,更新资源优先级
|
||||
self.update_subscribe_priority(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, downloads=downloads)
|
||||
elif subscribe.current_priority == 100:
|
||||
# 洗版完成
|
||||
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
else:
|
||||
# 洗版,未下载到内容
|
||||
logger.info(f'{mediainfo.title_year} 继续洗版 ...')
|
||||
@@ -544,9 +507,6 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
logger.warn('没有缓存资源,无法匹配订阅')
|
||||
return
|
||||
|
||||
# 记录重新识别过的种子
|
||||
_recognize_cached = []
|
||||
|
||||
with self._rlock:
|
||||
logger.debug(f"match lock acquired at {datetime.now()}")
|
||||
# 所有订阅
|
||||
@@ -579,54 +539,19 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
continue
|
||||
# 非洗版
|
||||
if not subscribe.best_version:
|
||||
# 每季总集数
|
||||
totals = {}
|
||||
if subscribe.season and subscribe.total_episode:
|
||||
totals = {
|
||||
subscribe.season: subscribe.total_episode
|
||||
}
|
||||
# 查询缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
totals=totals
|
||||
)
|
||||
else:
|
||||
# 洗版
|
||||
exist_flag = False
|
||||
if meta.type == MediaType.TV:
|
||||
no_exists = {
|
||||
mediakey: {
|
||||
subscribe.season: NotExistMediaInfo(
|
||||
season=subscribe.season,
|
||||
episodes=[],
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode or 1)
|
||||
}
|
||||
}
|
||||
else:
|
||||
no_exists = {}
|
||||
|
||||
# 已存在
|
||||
# 如果媒体已存在或已下载完毕,跳过当前订阅处理
|
||||
exist_flag, no_exists = self.check_and_handle_existing_media(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
mediakey=mediakey)
|
||||
if exist_flag:
|
||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)
|
||||
continue
|
||||
|
||||
# 电视剧订阅
|
||||
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 subscribe.custom_words:
|
||||
custom_words_list = subscribe.custom_words.split("\n")
|
||||
else:
|
||||
custom_words_list = None
|
||||
|
||||
# 遍历缓存种子
|
||||
_match_context = []
|
||||
@@ -649,45 +574,44 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
continue
|
||||
|
||||
# 有自定义识别词时,需要判断是否需要重新识别
|
||||
if subscribe.custom_words:
|
||||
apply_words = None
|
||||
if custom_words_list:
|
||||
_, apply_words = WordsMatcher().prepare(torrent_info.title,
|
||||
custom_words=subscribe.custom_words.split("\n"))
|
||||
custom_words=custom_words_list)
|
||||
if apply_words:
|
||||
logger.info(
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据...')
|
||||
# 重新识别元数据
|
||||
torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
|
||||
custom_words=subscribe.custom_words)
|
||||
custom_words=custom_words_list)
|
||||
# 媒体信息需要重新识别
|
||||
torrent_mediainfo = None
|
||||
|
||||
# 先判断是否有没识别的种子,否则重新识别
|
||||
if not torrent_mediainfo \
|
||||
or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||
# 避免重复处理
|
||||
_cache_key = f"{torrent_meta.org_string}_{torrent_info.description}"
|
||||
if _cache_key not in _recognize_cached:
|
||||
_recognize_cached.append(_cache_key)
|
||||
# 重新识别媒体信息
|
||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta)
|
||||
if torrent_mediainfo:
|
||||
# 更新种子缓存
|
||||
# 重新识别媒体信息
|
||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta)
|
||||
if torrent_mediainfo:
|
||||
# 更新种子缓存
|
||||
if not apply_words:
|
||||
context.media_info = torrent_mediainfo
|
||||
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
|
||||
else:
|
||||
# 通过标题匹配兜底
|
||||
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
|
||||
# 更新种子缓存
|
||||
if not apply_words:
|
||||
context.media_info = mediainfo
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# 直接比对媒体信息
|
||||
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
|
||||
@@ -791,10 +715,16 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
save_path=subscribe.save_path,
|
||||
media_category=subscribe.media_category,
|
||||
downloader=subscribe.downloader,
|
||||
source="Subscribe")
|
||||
source=self.get_subscribe_source_keyword(subscribe)
|
||||
)
|
||||
|
||||
# 同步外部修改,更新订阅信息
|
||||
subscribe = self.subscribeoper.get(subscribe.id)
|
||||
|
||||
# 判断是否要完成订阅
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||
downloads=downloads, lefts=lefts)
|
||||
if subscribe:
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||
downloads=downloads, lefts=lefts)
|
||||
logger.debug(f"match Lock released at {datetime.now()}")
|
||||
|
||||
def check(self):
|
||||
@@ -856,7 +786,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
|
||||
"""
|
||||
更新已下载集数到note字段
|
||||
更新已下载信息到note字段
|
||||
"""
|
||||
# 查询现有Note
|
||||
if not downloads:
|
||||
@@ -867,71 +797,85 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
for context in downloads:
|
||||
meta = context.meta_info
|
||||
mediainfo = context.media_info
|
||||
if mediainfo.type != MediaType.TV:
|
||||
continue
|
||||
if subscribe.tmdbid and mediainfo.tmdb_id \
|
||||
and mediainfo.tmdb_id != subscribe.tmdbid:
|
||||
continue
|
||||
if subscribe.doubanid and mediainfo.douban_id \
|
||||
and mediainfo.douban_id != subscribe.doubanid:
|
||||
continue
|
||||
episodes = meta.episode_list
|
||||
if not episodes:
|
||||
items = []
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧有集数,使用 episode_list
|
||||
items = meta.episode_list
|
||||
elif mediainfo.type == MediaType.MOVIE:
|
||||
# 电影只有一个条目,设置为 [1]
|
||||
items = [1]
|
||||
if not items:
|
||||
continue
|
||||
# 合并已下载集
|
||||
note = list(set(note).union(set(episodes)))
|
||||
# 更新订阅
|
||||
# 合并已下载的集数或电影项(去重)
|
||||
note = list(set(note).union(set(items)))
|
||||
# 更新订阅
|
||||
if note:
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"note": note
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def __get_downloaded_episodes(subscribe: Subscribe) -> List[int]:
|
||||
def __get_downloaded(subscribe: Subscribe) -> List[int]:
|
||||
"""
|
||||
获取已下载过的集数
|
||||
获取已下载过的集数或电影
|
||||
"""
|
||||
if not subscribe.note:
|
||||
if subscribe.best_version:
|
||||
return []
|
||||
if subscribe.type != MediaType.TV.value:
|
||||
note = subscribe.note or []
|
||||
if not note:
|
||||
return []
|
||||
episodes = subscribe.note or []
|
||||
logger.info(f'订阅 {subscribe.name} 第{subscribe.season}季 已下载集数:{episodes}')
|
||||
return episodes
|
||||
# 针对 TV 类型,返回已下载的集数
|
||||
if subscribe.type == MediaType.TV.value:
|
||||
logger.info(f'订阅 {subscribe.name} 第{subscribe.season}季 已下载集数:{note}')
|
||||
return note
|
||||
# 针对 Movie 类型,直接返回已下载的电影
|
||||
if subscribe.type == MediaType.MOVIE.value:
|
||||
logger.info(f'订阅 {subscribe.name} 已下载内容:{note}')
|
||||
return note
|
||||
return []
|
||||
|
||||
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MediaInfo,
|
||||
update_date: bool = False):
|
||||
"""
|
||||
更新订阅剩余集数
|
||||
更新订阅剩余集数及时间
|
||||
"""
|
||||
if not lefts:
|
||||
return
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
left_seasons = lefts.get(mediakey)
|
||||
if left_seasons:
|
||||
for season_info in left_seasons.values():
|
||||
season = season_info.season
|
||||
if season == subscribe.season:
|
||||
left_episodes = season_info.episodes
|
||||
if not left_episodes:
|
||||
lack_episode = season_info.total_episode
|
||||
else:
|
||||
lack_episode = len(left_episodes)
|
||||
logger.info(f'{mediainfo.title_year} 季 {season} 更新缺失集数为{lack_episode} ...')
|
||||
if update_date:
|
||||
# 同时更新最后时间
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"lack_episode": lack_episode,
|
||||
"last_update": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
else:
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"lack_episode": lack_episode
|
||||
})
|
||||
update_data = {}
|
||||
if update_date:
|
||||
update_data["last_update"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
if subscribe.type == MediaType.TV.value:
|
||||
if not lefts:
|
||||
# 如果 lefts 为空,表示没有缺失集数,直接设置 lack_episode 为 0
|
||||
lack_episode = 0
|
||||
logger.info(f'{mediainfo.title_year} 没有缺失集数,直接更新为 0 ...')
|
||||
else:
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
left_seasons = lefts.get(mediakey)
|
||||
lack_episode = 0
|
||||
if left_seasons:
|
||||
for season_info in left_seasons.values():
|
||||
season = season_info.season
|
||||
if season == subscribe.season:
|
||||
left_episodes = season_info.episodes
|
||||
if not left_episodes:
|
||||
lack_episode = season_info.total_episode
|
||||
else:
|
||||
lack_episode = len(left_episodes)
|
||||
logger.info(f"{mediainfo.title_year} 季 {season} 更新缺失集数为{lack_episode} ...")
|
||||
break
|
||||
update_data["lack_episode"] = lack_episode
|
||||
# 更新数据库
|
||||
if update_data:
|
||||
self.subscribeoper.update(subscribe.id, update_data)
|
||||
|
||||
def __finish_subscribe(self, subscribe: Subscribe, mediainfo: MediaInfo,
|
||||
meta: MetaBase, bestversion: bool = False):
|
||||
def __finish_subscribe(self, subscribe: Subscribe, mediainfo: MediaInfo, meta: MetaBase):
|
||||
"""
|
||||
完成订阅
|
||||
"""
|
||||
@@ -939,9 +883,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
if subscribe.state == "P":
|
||||
return
|
||||
# 完成订阅
|
||||
msgstr = "订阅"
|
||||
if bestversion:
|
||||
msgstr = "洗版"
|
||||
msgstr = "订阅" if not subscribe.best_version else "洗版"
|
||||
logger.info(f'{mediainfo.title_year} 完成{msgstr}')
|
||||
# 新增订阅历史
|
||||
self.subscribeoper.add_history(**subscribe.to_dict())
|
||||
@@ -1037,7 +979,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
total_episode: int,
|
||||
start_episode: int,
|
||||
downloaded_episodes: List[int] = None
|
||||
) -> Dict[Union[int, str], Dict[int, NotExistMediaInfo]]:
|
||||
) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数
|
||||
:param subscribe_name: 订阅名称
|
||||
@@ -1050,7 +992,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
# 使用订阅的总集数和开始集数替换no_exists
|
||||
if not no_exists or not no_exists.get(mediakey):
|
||||
return no_exists
|
||||
return False, no_exists
|
||||
no_exists_item = no_exists.get(mediakey)
|
||||
if total_episode or start_episode:
|
||||
logger.info(f'订阅 {subscribe_name} 设定的开始集数:{start_episode}、总集数:{total_episode}')
|
||||
@@ -1075,7 +1017,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
if not start_episode \
|
||||
and not total_episode:
|
||||
# 无需调整
|
||||
return no_exists
|
||||
return False, no_exists
|
||||
if not start_episode:
|
||||
# 没有自定义开始集
|
||||
start_episode = start
|
||||
@@ -1110,25 +1052,32 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
episode_list = list(range(start, total + 1))
|
||||
# 更新剧集列表
|
||||
episodes = list(set(episode_list).difference(set(downloaded_episodes)))
|
||||
# 如果存在已下载剧集,则差集为空时,说明所有均已存在
|
||||
if not episodes:
|
||||
return True, {}
|
||||
# 更新集合
|
||||
no_exists[mediakey][begin_season] = NotExistMediaInfo(
|
||||
season=begin_season,
|
||||
episodes=episodes,
|
||||
total_episode=total,
|
||||
start_episode=start
|
||||
start_episode=start,
|
||||
)
|
||||
else:
|
||||
# 开始集数
|
||||
start = start_episode or 1
|
||||
# 不存在的季
|
||||
# 更新剧集列表
|
||||
episodes = list(set(range(start, total_episode + 1)).difference(set(downloaded_episodes)))
|
||||
# 如果存在已下载剧集,则差集为空时,说明所有均已存在
|
||||
if not episodes:
|
||||
return True, {}
|
||||
no_exists[mediakey][begin_season] = NotExistMediaInfo(
|
||||
season=begin_season,
|
||||
episodes=list(set(range(start, total_episode + 1)).difference(set(downloaded_episodes))),
|
||||
episodes=episodes,
|
||||
total_episode=total_episode,
|
||||
start_episode=start
|
||||
start_episode=start,
|
||||
)
|
||||
logger.info(f'订阅 {subscribe_name} 缺失剧集数更新为:{no_exists}')
|
||||
return no_exists
|
||||
return False, no_exists
|
||||
|
||||
@eventmanager.register(EventType.SiteDeleted)
|
||||
def remove_site(self, event: Event):
|
||||
@@ -1148,7 +1097,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
if not subscribe.sites:
|
||||
continue
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"sites": ""
|
||||
"sites": []
|
||||
})
|
||||
return
|
||||
# 从选中的rss站点中移除
|
||||
@@ -1317,6 +1266,90 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
subscribe_info.episodes = episodes
|
||||
return subscribe_info
|
||||
|
||||
def check_and_handle_existing_media(self, subscribe: Subscribe, meta: MetaInfo,
|
||||
mediainfo: MediaInfo, mediakey: str):
|
||||
"""
|
||||
检查媒体是否已经存在,并根据情况执行相应的操作
|
||||
1. 查询缺失的媒体信息
|
||||
2. 判断是否已经下载完毕
|
||||
3. 根据媒体类型(电视剧或电影)执行不同的处理
|
||||
|
||||
:param subscribe: 订阅信息对象
|
||||
:param meta: 媒体元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param mediakey: 媒体标识符
|
||||
:return:
|
||||
- exist_flag (bool): 布尔值,表示媒体是否已经完全下载或已存在
|
||||
- no_exists (dict): 缺失的媒体信息,包含缺失的集数或其他相关信息
|
||||
"""
|
||||
# 非洗版
|
||||
if not subscribe.best_version:
|
||||
# 每季总集数
|
||||
totals = {}
|
||||
if subscribe.season and subscribe.total_episode:
|
||||
totals = {
|
||||
subscribe.season: subscribe.total_episode
|
||||
}
|
||||
# 查询媒体库缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
totals=totals
|
||||
)
|
||||
else:
|
||||
# 洗版,如果已经满足了优先级,则认为已经洗版完成
|
||||
if subscribe.current_priority == 100:
|
||||
exist_flag = True
|
||||
no_exists = {}
|
||||
else:
|
||||
exist_flag = False
|
||||
if meta.type == MediaType.TV:
|
||||
# 对于电视剧,构造缺失的媒体信息
|
||||
no_exists = {
|
||||
mediakey: {
|
||||
subscribe.season: NotExistMediaInfo(
|
||||
season=subscribe.season,
|
||||
episodes=[],
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode or 1)
|
||||
}
|
||||
}
|
||||
else:
|
||||
no_exists = {}
|
||||
|
||||
# 如果媒体已存在,执行订阅完成操作
|
||||
if exist_flag:
|
||||
if not subscribe.best_version:
|
||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)
|
||||
return True, no_exists
|
||||
|
||||
# 获取已下载的集数或电影
|
||||
downloaded = self.__get_downloaded(subscribe)
|
||||
if meta.type == MediaType.TV:
|
||||
# 对于电视剧类型,整合缺失集数并剔除已下载的集数
|
||||
exist_flag, 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=downloaded
|
||||
)
|
||||
elif meta.type == MediaType.MOVIE:
|
||||
# 对于电影类型,直接根据是否已下载判断
|
||||
exist_flag = bool(downloaded)
|
||||
|
||||
# 如果已下载完毕,执行订阅完成操作
|
||||
if exist_flag:
|
||||
logger.info(f'{mediainfo.title_year} 已全部下载')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)
|
||||
return True, no_exists
|
||||
|
||||
# 返回结果,表示媒体未完全下载或存在
|
||||
return False, no_exists
|
||||
|
||||
@staticmethod
|
||||
def get_states_for_search(state: str) -> str:
|
||||
"""
|
||||
@@ -1332,3 +1365,24 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
if state in ["R", "P"]:
|
||||
return "R,P"
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def get_subscribe_source_keyword(subscribe: Subscribe) -> str:
|
||||
"""
|
||||
构造用于订阅来源的关键字字符串
|
||||
:param subscribe: Subscribe 对象
|
||||
:return: 格式化的订阅来源关键字字符串,格式为 "Subscribe|{...}"
|
||||
"""
|
||||
source_keyword = {
|
||||
'id': subscribe.id,
|
||||
'name': subscribe.name,
|
||||
'year': subscribe.year,
|
||||
'type': subscribe.type,
|
||||
'season': subscribe.season,
|
||||
'tmdbid': subscribe.tmdbid,
|
||||
'imdbid': subscribe.imdbid,
|
||||
'tvdbid': subscribe.tvdbid,
|
||||
'doubanid': subscribe.doubanid,
|
||||
'bangumiid': subscribe.bangumiid
|
||||
}
|
||||
return f"Subscribe|{json.dumps(source_keyword, ensure_ascii=False)}"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from app.chain import ChainBase
|
||||
@@ -161,4 +162,15 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
获取前端版本
|
||||
"""
|
||||
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:
|
||||
version = str(f.read()).strip()
|
||||
return version
|
||||
except Exception as err:
|
||||
logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||
return FRONTEND_VERSION
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ from app.core.security import get_password_hash, verify_password
|
||||
from app.db.models.user import User
|
||||
from app.db.user_oper import UserOper
|
||||
from app.log import logger
|
||||
from app.schemas.event import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas.types import ChainEventType
|
||||
from app.utils.otp import OtpUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
@@ -15,15 +15,18 @@ from app.helper.message import MessageHelper
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas import Notification
|
||||
from app.schemas.event import CommandRegisterEventData
|
||||
from app.schemas import Notification, CommandRegisterEventData
|
||||
from app.schemas.types import EventType, MessageChannel, ChainEventType
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.structures import DictUtils
|
||||
|
||||
|
||||
class CommandChain(ChainBase, metaclass=Singleton):
|
||||
class CommandChain(ChainBase):
|
||||
pass
|
||||
|
||||
|
||||
class Command(metaclass=Singleton):
|
||||
"""
|
||||
全局命令管理,消费事件
|
||||
"""
|
||||
@@ -210,7 +213,7 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
||||
if filtered_initial_commands != self._registered_commands or force_register:
|
||||
logger.debug("Command set has changed or force registration is enabled.")
|
||||
self._registered_commands = filtered_initial_commands
|
||||
super().register_commands(commands=filtered_initial_commands)
|
||||
CommandChain().register_commands(commands=filtered_initial_commands)
|
||||
else:
|
||||
logger.debug("Command set unchanged, skipping broadcast registration.")
|
||||
except Exception as e:
|
||||
@@ -248,7 +251,7 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
||||
event = eventmanager.send_event(ChainEventType.CommandRegister, event_data)
|
||||
return event, commands
|
||||
|
||||
def __build_plugin_commands(self, pid: Optional[str] = None) -> Dict[str, dict]:
|
||||
def __build_plugin_commands(self, _: Optional[str] = None) -> Dict[str, dict]:
|
||||
"""
|
||||
构建插件命令
|
||||
"""
|
||||
@@ -277,7 +280,7 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
||||
if command.get("type") == "scheduler":
|
||||
# 定时服务
|
||||
if userid:
|
||||
self.post_message(
|
||||
CommandChain().post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
@@ -290,7 +293,7 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
||||
self.scheduler.start(job_id=command.get("id"))
|
||||
|
||||
if userid:
|
||||
self.post_message(
|
||||
CommandChain().post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
@@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional, Tuple, Type
|
||||
from dotenv import set_key
|
||||
from pydantic import BaseModel, BaseSettings, validator, Field
|
||||
|
||||
from app.log import logger
|
||||
from app.log import logger, log_settings, LogConfigModel
|
||||
from app.utils.system import SystemUtils
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
@@ -240,9 +240,11 @@ class ConfigModel(BaseModel):
|
||||
RENAME_FORMAT_S0_NAMES: List[str] = Field(
|
||||
default_factory=lambda: ["Specials", "SPs"]
|
||||
)
|
||||
# 启用分词搜索
|
||||
TOKENIZED_SEARCH: bool = False
|
||||
|
||||
|
||||
class Settings(BaseSettings, ConfigModel):
|
||||
class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"""
|
||||
系统配置类
|
||||
"""
|
||||
@@ -349,7 +351,7 @@ class Settings(BaseSettings, ConfigModel):
|
||||
return default, True
|
||||
|
||||
@validator('*', pre=True, always=True)
|
||||
def generic_type_validator(cls, value: Any, field):
|
||||
def generic_type_validator(cls, value: Any, field): # noqa
|
||||
"""
|
||||
通用校验器,尝试将配置值转换为期望的类型
|
||||
"""
|
||||
@@ -404,6 +406,8 @@ class Settings(BaseSettings, ConfigModel):
|
||||
# 仅成功更新配置时,才更新内存
|
||||
if success:
|
||||
setattr(self, key, converted_value)
|
||||
if hasattr(log_settings, key):
|
||||
setattr(log_settings, key, converted_value)
|
||||
return success, message
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
@@ -414,8 +418,21 @@ class Settings(BaseSettings, ConfigModel):
|
||||
更新多个配置项
|
||||
"""
|
||||
results = {}
|
||||
log_updated, plugin_monitor_updated = False, False
|
||||
for k, v in env.items():
|
||||
results[k] = self.update_setting(k, v)
|
||||
if hasattr(log_settings, k):
|
||||
log_updated = True
|
||||
if k in ["PLUGIN_AUTO_RELOAD", "DEV"]:
|
||||
plugin_monitor_updated = True
|
||||
# 本次更新存在日志配置项更新,需要重新加载日志配置
|
||||
if log_updated:
|
||||
logger.update_loggers()
|
||||
# 本次更新存在插件监控配置项更新,需要重新加载插件监控
|
||||
if plugin_monitor_updated:
|
||||
# 解决顶层循环导入问题
|
||||
from app.core.plugin import PluginManager
|
||||
PluginManager().reload_monitor()
|
||||
return results
|
||||
|
||||
@property
|
||||
@@ -481,6 +498,7 @@ class Settings(BaseSettings, ConfigModel):
|
||||
"refresh": 100,
|
||||
"tmdb": 1024,
|
||||
"douban": 512,
|
||||
"bangumi": 512,
|
||||
"fanart": 512,
|
||||
"meta": (self.META_CACHE_EXPIRE or 24) * 3600
|
||||
}
|
||||
@@ -489,6 +507,7 @@ class Settings(BaseSettings, ConfigModel):
|
||||
"refresh": 50,
|
||||
"tmdb": 256,
|
||||
"douban": 256,
|
||||
"bangumi": 256,
|
||||
"fanart": 128,
|
||||
"meta": (self.META_CACHE_EXPIRE or 2) * 3600
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -123,11 +124,25 @@ 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):
|
||||
"""
|
||||
返回字典
|
||||
"""
|
||||
dicts = asdict(self)
|
||||
dicts = vars(self).copy()
|
||||
dicts["volume_factor"] = self.volume_factor
|
||||
dicts["freedate_diff"] = self.freedate_diff
|
||||
return dicts
|
||||
@@ -725,7 +740,7 @@ class MediaInfo:
|
||||
"""
|
||||
返回字典
|
||||
"""
|
||||
dicts = asdict(self)
|
||||
dicts = vars(self).copy()
|
||||
dicts["type"] = self.type.value if self.type else None
|
||||
dicts["detail_link"] = self.detail_link
|
||||
dicts["title_year"] = self.title_year
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Callable, Dict, List, Optional, Union
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.log import logger
|
||||
from app.schemas.event import ChainEventData
|
||||
from app.schemas import ChainEventData
|
||||
from app.schemas.types import ChainEventType, EventType
|
||||
from app.utils.limit import ExponentialBackoffRateLimiter
|
||||
from app.utils.singleton import Singleton
|
||||
@@ -293,7 +293,7 @@ class EventManager(metaclass=Singleton):
|
||||
|
||||
# 对于类实例(实现了 __call__ 方法)
|
||||
if not inspect.isfunction(handler) and hasattr(handler, "__call__"):
|
||||
handler_cls = handler.__class__
|
||||
handler_cls = handler.__class__ # noqa
|
||||
return cls.__get_handler_identifier(handler_cls)
|
||||
|
||||
# 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息
|
||||
@@ -438,12 +438,15 @@ class EventManager(metaclass=Singleton):
|
||||
|
||||
# 如果类不在全局变量中,尝试动态导入模块并创建实例
|
||||
try:
|
||||
# 导入模块,除了插件,只有chain能响应事件
|
||||
if not class_name.endswith("Chain"):
|
||||
if class_name == "Command":
|
||||
module_name = "app.command"
|
||||
module = importlib.import_module(module_name)
|
||||
elif class_name.endswith("Chain"):
|
||||
module_name = f"app.chain.{class_name[:-5].lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
else:
|
||||
logger.debug(f"事件处理出错:无效的 Chain 类名: {class_name},类名必须以 'Chain' 结尾")
|
||||
return None
|
||||
module_name = f"app.chain.{class_name[:-5].lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
if hasattr(module, class_name):
|
||||
class_obj = getattr(module, class_name)()
|
||||
return class_obj
|
||||
@@ -502,13 +505,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):
|
||||
@@ -516,23 +521,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}")
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import traceback
|
||||
from dataclasses import dataclass, asdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Union, Optional, List, Self
|
||||
|
||||
import cn2an
|
||||
import regex as re
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -589,9 +589,10 @@ class MetaBase(object):
|
||||
"""
|
||||
转为字典
|
||||
"""
|
||||
dicts = asdict(self)
|
||||
dicts = vars(self).copy()
|
||||
dicts["type"] = self.type.value if self.type else None
|
||||
dicts["season_episode"] = self.season_episode
|
||||
dicts["edition"] = self.edition
|
||||
dicts["name"] = self.name
|
||||
dicts["episode_list"] = self.episode_list
|
||||
return dicts
|
||||
|
||||
@@ -220,11 +220,23 @@ class PluginManager(metaclass=Singleton):
|
||||
self._running_plugins = {}
|
||||
logger.info("插件停止完成")
|
||||
|
||||
def reload_monitor(self):
|
||||
"""
|
||||
重新加载插件文件修改监测
|
||||
"""
|
||||
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
|
||||
if self._observer and self._observer.is_alive():
|
||||
logger.info("插件文件修改监测已经在运行中...")
|
||||
else:
|
||||
self.__start_monitor()
|
||||
else:
|
||||
self.stop_monitor()
|
||||
|
||||
def __start_monitor(self):
|
||||
"""
|
||||
开发者模式下监测插件文件修改
|
||||
启用监测插件文件修改监测
|
||||
"""
|
||||
logger.info("开发者模式下开始监测插件文件修改...")
|
||||
logger.info("开始监测插件文件修改...")
|
||||
monitor_handler = PluginMonitorHandler()
|
||||
self._observer = Observer()
|
||||
self._observer.schedule(monitor_handler, str(settings.ROOT_PATH / "app" / "plugins"), recursive=True)
|
||||
@@ -232,14 +244,16 @@ class PluginManager(metaclass=Singleton):
|
||||
|
||||
def stop_monitor(self):
|
||||
"""
|
||||
停止监测插件修改
|
||||
停止监测插件文件修改监测
|
||||
"""
|
||||
# 停止监测
|
||||
if self._observer:
|
||||
if self._observer and self._observer.is_alive():
|
||||
logger.info("正在停止插件文件修改监测...")
|
||||
self._observer.stop()
|
||||
self._observer.join()
|
||||
logger.info("插件文件修改监测停止完成")
|
||||
else:
|
||||
logger.info("未启用插件文件修改监测,无需停止")
|
||||
|
||||
@staticmethod
|
||||
def __stop_plugin(plugin: Any):
|
||||
@@ -668,7 +682,7 @@ class PluginManager(metaclass=Singleton):
|
||||
# 相同 ID 的插件保留版本号最大的版本
|
||||
max_versions = {}
|
||||
for p in all_plugins:
|
||||
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, max_versions[p.id]) > 0:
|
||||
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, ">", max_versions[p.id]):
|
||||
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)} 个线上插件")
|
||||
@@ -809,7 +823,7 @@ class PluginManager(metaclass=Singleton):
|
||||
plugin.has_update = False
|
||||
if plugin_static:
|
||||
installed_version = getattr(plugin_static, "plugin_version")
|
||||
if StringUtils.compare_version(installed_version, plugin_info.get("version")) < 0:
|
||||
if StringUtils.compare_version(installed_version, "<", plugin_info.get("version")):
|
||||
# 需要更新
|
||||
plugin.has_update = True
|
||||
# 运行状态
|
||||
|
||||
@@ -286,7 +286,7 @@ def decrypt(data: bytes, key: bytes) -> Optional[bytes]:
|
||||
return None
|
||||
|
||||
|
||||
def encrypt_message(message: str, key: bytes):
|
||||
def encrypt_message(message: str, key: bytes) -> str:
|
||||
"""
|
||||
使用给定的key对消息进行加密,并返回加密后的字符串
|
||||
"""
|
||||
@@ -295,14 +295,14 @@ def encrypt_message(message: str, key: bytes):
|
||||
return encrypted_message.decode()
|
||||
|
||||
|
||||
def hash_sha256(message):
|
||||
def hash_sha256(message: str) -> str:
|
||||
"""
|
||||
对字符串做hash运算
|
||||
"""
|
||||
return hashlib.sha256(message.encode()).hexdigest()
|
||||
|
||||
|
||||
def aes_decrypt(data, key):
|
||||
def aes_decrypt(data: str, key: str) -> str:
|
||||
"""
|
||||
AES解密
|
||||
"""
|
||||
@@ -322,7 +322,7 @@ def aes_decrypt(data, key):
|
||||
return result.decode('utf-8')
|
||||
|
||||
|
||||
def aes_encrypt(data, key):
|
||||
def aes_encrypt(data: str, key: str) -> str:
|
||||
"""
|
||||
AES加密
|
||||
"""
|
||||
@@ -338,7 +338,7 @@ def aes_encrypt(data, key):
|
||||
return base64.b64encode(cipher.iv + result).decode('utf-8')
|
||||
|
||||
|
||||
def nexusphp_encrypt(data_str: str, key):
|
||||
def nexusphp_encrypt(data_str: str, key: bytes) -> str:
|
||||
"""
|
||||
NexusPHP加密
|
||||
"""
|
||||
|
||||
@@ -13,7 +13,7 @@ connect_args = {
|
||||
# 启用 WAL 模式时的额外配置
|
||||
if settings.DB_WAL_ENABLE:
|
||||
connect_args["check_same_thread"] = False
|
||||
kwargs = {
|
||||
db_kwargs = {
|
||||
"url": f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
||||
"pool_pre_ping": settings.DB_POOL_PRE_PING,
|
||||
"echo": settings.DB_ECHO,
|
||||
@@ -23,13 +23,13 @@ kwargs = {
|
||||
}
|
||||
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
|
||||
if pool_class == QueuePool:
|
||||
kwargs.update({
|
||||
db_kwargs.update({
|
||||
"pool_size": settings.DB_POOL_SIZE,
|
||||
"pool_timeout": settings.DB_POOL_TIMEOUT,
|
||||
"max_overflow": settings.DB_MAX_OVERFLOW
|
||||
})
|
||||
# 创建数据库引擎
|
||||
Engine = create_engine(**kwargs)
|
||||
Engine = create_engine(**db_kwargs)
|
||||
# 根据配置设置日志模式
|
||||
journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
|
||||
with Engine.connect() as connection:
|
||||
@@ -198,7 +198,7 @@ class Base:
|
||||
@classmethod
|
||||
@db_query
|
||||
def get(cls, db: Session, rid: int) -> Self:
|
||||
return db.query(cls).filter(cls.id == rid).first()
|
||||
return db.query(cls).filter(and_(cls.id == rid)).first()
|
||||
|
||||
@db_update
|
||||
def update(self, db: Session, payload: dict):
|
||||
@@ -225,7 +225,7 @@ class Base:
|
||||
return list(result)
|
||||
|
||||
def to_dict(self):
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa
|
||||
|
||||
@declared_attr
|
||||
def __tablename__(self) -> str:
|
||||
|
||||
@@ -11,7 +11,7 @@ def init_db():
|
||||
初始化数据库
|
||||
"""
|
||||
# 全量建表
|
||||
Base.metadata.create_all(bind=Engine)
|
||||
Base.metadata.create_all(bind=Engine) # noqa
|
||||
|
||||
|
||||
def update_db():
|
||||
|
||||
@@ -57,7 +57,7 @@ class MessageOper(DbOper):
|
||||
|
||||
# 从kwargs中去掉Message中没有的字段
|
||||
for k in list(kwargs.keys()):
|
||||
if k not in Message.__table__.columns.keys():
|
||||
if k not in Message.__table__.columns.keys(): # noqa
|
||||
kwargs.pop(k)
|
||||
|
||||
Message(**kwargs).create(self._db)
|
||||
|
||||
@@ -29,6 +29,8 @@ class DownloadHistory(Base):
|
||||
episodes = Column(String)
|
||||
# 海报
|
||||
image = Column(String)
|
||||
# 下载器
|
||||
downloader = Column(String)
|
||||
# 下载任务Hash
|
||||
download_hash = Column(String, index=True)
|
||||
# 种子名称
|
||||
@@ -168,10 +170,10 @@ class DownloadFiles(Base):
|
||||
下载文件记录
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
# 下载任务Hash
|
||||
download_hash = Column(String, index=True)
|
||||
# 下载器
|
||||
downloader = Column(String)
|
||||
# 下载任务Hash
|
||||
download_hash = Column(String, index=True)
|
||||
# 完整路径
|
||||
fullpath = Column(String, index=True)
|
||||
# 保存路径
|
||||
|
||||
@@ -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 is None)
|
||||
.filter(or_(SiteUserData.err_msg.is_(None), SiteUserData.err_msg == ""))
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.db import db_query, db_update, Base
|
||||
|
||||
class TransferHistory(Base):
|
||||
"""
|
||||
转移历史记录
|
||||
整理记录
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
# 源路径
|
||||
@@ -43,6 +43,8 @@ class TransferHistory(Base):
|
||||
episodes = Column(String)
|
||||
# 海报
|
||||
image = Column(String)
|
||||
# 下载器
|
||||
downloader = Column(String)
|
||||
# 下载器hash
|
||||
download_hash = Column(String, index=True)
|
||||
# 转移成功状态
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -120,7 +120,7 @@ class TransferHistoryOper(DbOper):
|
||||
|
||||
def add_success(self, fileitem: FileItem, mode: str, meta: MetaBase,
|
||||
mediainfo: MediaInfo, transferinfo: TransferInfo,
|
||||
download_hash: str = None):
|
||||
downloader: str = None, download_hash: str = None):
|
||||
"""
|
||||
新增转移成功历史记录
|
||||
"""
|
||||
@@ -143,13 +143,14 @@ class TransferHistoryOper(DbOper):
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
image=mediainfo.get_poster_image(),
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
status=1,
|
||||
files=transferinfo.file_list
|
||||
)
|
||||
|
||||
def add_fail(self, fileitem: FileItem, mode: str, meta: MetaBase, mediainfo: MediaInfo = None,
|
||||
transferinfo: TransferInfo = None, download_hash: str = None):
|
||||
transferinfo: TransferInfo = None, downloader: str = None, download_hash: str = None):
|
||||
"""
|
||||
新增转移失败历史记录
|
||||
"""
|
||||
@@ -173,6 +174,7 @@ class TransferHistoryOper(DbOper):
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
image=mediainfo.get_poster_image(),
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
status=0,
|
||||
errmsg=transferinfo.message or '未知错误',
|
||||
@@ -188,6 +190,7 @@ class TransferHistoryOper(DbOper):
|
||||
mode=mode,
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
status=0,
|
||||
errmsg="未识别到媒体信息"
|
||||
|
||||
@@ -68,10 +68,16 @@ class DirectoryHelper:
|
||||
# 电影/电视剧
|
||||
media_type = media.type.value
|
||||
dirs = self.get_dirs()
|
||||
|
||||
# 如果存在源目录,并源目录为任一下载目录的子目录时,则进行源目录匹配,否则,允许源目录按同盘优先的逻辑匹配
|
||||
matching_dirs = [d for d in dirs if src_path.is_relative_to(d.download_path)] if src_path else []
|
||||
# 根据是否有匹配的源目录,决定要考虑的目录集合
|
||||
dirs_to_consider = matching_dirs if matching_dirs else dirs
|
||||
|
||||
# 已匹配的目录
|
||||
matched_dirs: List[schemas.TransferDirectoryConf] = []
|
||||
# 按照配置顺序查找
|
||||
for d in dirs:
|
||||
for d in dirs_to_consider:
|
||||
# 没有启用整理的目录
|
||||
if not d.monitor_type and not include_unsorted:
|
||||
continue
|
||||
@@ -81,9 +87,6 @@ class DirectoryHelper:
|
||||
# 目标存储类型不匹配
|
||||
if target_storage and d.library_storage != target_storage:
|
||||
continue
|
||||
# 有源目录时,源目录不匹配下载目录
|
||||
if src_path and not src_path.is_relative_to(d.download_path):
|
||||
continue
|
||||
# 有目标目录时,目标目录不匹配媒体库目录
|
||||
if dest_path and dest_path != Path(d.library_path):
|
||||
continue
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import Tuple, Optional
|
||||
|
||||
import parse
|
||||
|
||||
from app.core.meta.metabase import MetaBase
|
||||
|
||||
|
||||
class FormatParser(object):
|
||||
_key = ""
|
||||
@@ -77,25 +79,38 @@ 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信息
|
||||
"""
|
||||
# 指定的具体集数,直接返回
|
||||
if self._start_ep is not None and self._start_ep == self._end_ep:
|
||||
if isinstance(self._start_ep, str):
|
||||
s, e = self._start_ep.split("-")
|
||||
start_ep = self.__offset.replace("EP", s)
|
||||
end_ep = self.__offset.replace("EP", e)
|
||||
if int(s) == int(e):
|
||||
if self._start_ep is not None:
|
||||
if self._start_ep == self._end_ep:
|
||||
# `details` 格式为 `X-X` 或者 `X`
|
||||
if isinstance(self._start_ep, str):
|
||||
# `details` 格式为 `X-X`
|
||||
s, e = self._start_ep.split("-")
|
||||
start_ep = self.__offset.replace("EP", s)
|
||||
end_ep = self.__offset.replace("EP", e)
|
||||
if int(s) == int(e):
|
||||
return int(eval(start_ep)), None, self.part
|
||||
return int(eval(start_ep)), int(eval(end_ep)), self.part
|
||||
else:
|
||||
# `details` 格式为 `X`
|
||||
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
||||
return int(eval(start_ep)), None, self.part
|
||||
return int(eval(start_ep)), int(eval(end_ep)), self.part
|
||||
else:
|
||||
# `details` 格式为 `X,X`
|
||||
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
||||
return int(eval(start_ep)), None, self.part
|
||||
end_ep = self.__offset.replace("EP", str(self._end_ep))
|
||||
return int(eval(start_ep)), int(eval(end_ep)), 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
|
||||
end_ep = self.__offset.replace("EP", str(e)) if e else None
|
||||
|
||||
@@ -120,7 +120,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
"""
|
||||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||||
return {}
|
||||
res = RequestUtils(timeout=10).get_res(self._install_statistic)
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=10).get_res(self._install_statistic)
|
||||
if res and res.status_code == 200:
|
||||
return res.json()
|
||||
return {}
|
||||
@@ -134,7 +134,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
if not pid:
|
||||
return False
|
||||
install_reg_url = self._install_reg.format(pid=pid)
|
||||
res = RequestUtils(timeout=5).get_res(install_reg_url)
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5).get_res(install_reg_url)
|
||||
if res and res.status_code == 200:
|
||||
return True
|
||||
return False
|
||||
@@ -148,7 +148,8 @@ class PluginHelper(metaclass=Singleton):
|
||||
plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins)
|
||||
if not plugins:
|
||||
return False
|
||||
res = RequestUtils(content_type="application/json",
|
||||
res = RequestUtils(proxies=settings.PROXY,
|
||||
content_type="application/json",
|
||||
timeout=5).post(self._install_report,
|
||||
json={"plugins": [{"plugin_id": plugin} for plugin in plugins]})
|
||||
return True if res else False
|
||||
|
||||
@@ -70,7 +70,7 @@ class ResourceHelper(metaclass=Singleton):
|
||||
local_version = self.siteshelper.indexer_version
|
||||
else:
|
||||
continue
|
||||
if StringUtils.compare_version(version, local_version) > 0:
|
||||
if StringUtils.compare_version(version, ">", local_version):
|
||||
logger.info(f"{rname} 资源包有更新,最新版本:v{version}")
|
||||
else:
|
||||
continue
|
||||
|
||||
@@ -30,6 +30,8 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
|
||||
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
|
||||
|
||||
_shares_cache = TTLCache(maxsize=20, ttl=1800)
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
if settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
@@ -44,7 +46,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return []
|
||||
res = RequestUtils(timeout=15).get_res(self._sub_statistic, params={
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_statistic, params={
|
||||
"stype": stype,
|
||||
"page": page,
|
||||
"count": count
|
||||
@@ -59,7 +61,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False
|
||||
res = RequestUtils(timeout=5, headers={
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).post_res(self._sub_reg, json=sub)
|
||||
if res and res.status_code == 200:
|
||||
@@ -72,7 +74,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False
|
||||
res = RequestUtils(timeout=5, headers={
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).post_res(self._sub_done, json=sub)
|
||||
if res and res.status_code == 200:
|
||||
@@ -104,7 +106,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
subscribes = SubscribeOper().list()
|
||||
if not subscribes:
|
||||
return True
|
||||
res = RequestUtils(content_type="application/json",
|
||||
res = RequestUtils(proxies=settings.PROXY, content_type="application/json",
|
||||
timeout=10).post(self._sub_report,
|
||||
json={
|
||||
"subscribes": [
|
||||
@@ -125,7 +127,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
return False, "订阅不存在"
|
||||
subscribe_dict = subscribe.to_dict()
|
||||
subscribe_dict.pop("id")
|
||||
res = RequestUtils(content_type="application/json",
|
||||
res = RequestUtils(proxies=settings.PROXY, content_type="application/json",
|
||||
timeout=10).post(self._sub_share,
|
||||
json={
|
||||
"share_title": share_title,
|
||||
@@ -136,6 +138,8 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
if res is None:
|
||||
return False, "连接MoviePilot服务器失败"
|
||||
if res.ok:
|
||||
# 清除 get_shares 的缓存,以便实时看到结果
|
||||
self._shares_cache.clear()
|
||||
return True, ""
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
@@ -146,7 +150,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False, "当前没有开启订阅数据共享功能"
|
||||
res = RequestUtils(timeout=5, headers={
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).get_res(self._sub_fork % share_id)
|
||||
if res is None:
|
||||
@@ -156,14 +160,14 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
|
||||
@cached(cache=TTLCache(maxsize=20, ttl=1800))
|
||||
@cached(cache=_shares_cache)
|
||||
def get_shares(self, name: str, page: int = 1, count: int = 30) -> List[dict]:
|
||||
"""
|
||||
获取订阅分享数据
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return []
|
||||
res = RequestUtils(timeout=15).get_res(self._sub_shares, params={
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_shares, params={
|
||||
"name": name,
|
||||
"page": page,
|
||||
"count": count
|
||||
|
||||
@@ -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:
|
||||
|
||||
117
app/log.py
117
app/log.py
@@ -1,19 +1,24 @@
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
import click
|
||||
from pydantic import BaseSettings
|
||||
from pydantic import BaseSettings, BaseModel
|
||||
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class LogSettings(BaseSettings):
|
||||
class LogConfigModel(BaseModel):
|
||||
"""
|
||||
日志设置
|
||||
Pydantic 配置模型,描述所有配置项及其类型和默认值
|
||||
"""
|
||||
|
||||
class Config:
|
||||
extra = "ignore" # 忽略未定义的配置项
|
||||
|
||||
# 配置文件目录
|
||||
CONFIG_DIR: Optional[str] = None
|
||||
# 是否为调试模式
|
||||
@@ -29,6 +34,12 @@ class LogSettings(BaseSettings):
|
||||
# 文件日志格式
|
||||
LOG_FILE_FORMAT: str = "【%(levelname)s】%(asctime)s - %(message)s"
|
||||
|
||||
|
||||
class LogSettings(BaseSettings, LogConfigModel):
|
||||
"""
|
||||
日志设置类
|
||||
"""
|
||||
|
||||
@property
|
||||
def CONFIG_PATH(self):
|
||||
return SystemUtils.get_config_path(self.CONFIG_DIR)
|
||||
@@ -85,6 +96,8 @@ class LoggerManager:
|
||||
_loggers: Dict[str, Any] = {}
|
||||
# 默认日志文件名称
|
||||
_default_log_file = "moviepilot.log"
|
||||
# 线程锁
|
||||
_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def __get_caller():
|
||||
@@ -96,35 +109,54 @@ class LoggerManager:
|
||||
caller_name = None
|
||||
# 调用者插件名称
|
||||
plugin_name = None
|
||||
for i in inspect.stack()[3:]:
|
||||
filepath = Path(i.filename)
|
||||
|
||||
try:
|
||||
frame = sys._getframe(3) # noqa
|
||||
except (AttributeError, ValueError):
|
||||
# 如果无法获取帧,返回默认值
|
||||
return "log.py", None
|
||||
|
||||
while frame:
|
||||
filepath = Path(frame.f_code.co_filename)
|
||||
parts = filepath.parts
|
||||
# 设定调用者文件名称
|
||||
if not caller_name:
|
||||
# 设定调用者文件名称
|
||||
if parts[-1] == "__init__.py":
|
||||
if parts[-1] == "__init__.py" and len(parts) >= 2:
|
||||
caller_name = parts[-2]
|
||||
else:
|
||||
caller_name = parts[-1]
|
||||
# 设定调用者插件名称
|
||||
if "app" in parts:
|
||||
if not plugin_name and "plugins" in parts:
|
||||
# 设定调用者插件名称
|
||||
plugin_name = parts[parts.index("plugins") + 1]
|
||||
if plugin_name == "__init__.py":
|
||||
plugin_name = "plugin"
|
||||
break
|
||||
try:
|
||||
plugins_index = parts.index("plugins")
|
||||
if plugins_index + 1 < len(parts):
|
||||
plugin_candidate = parts[plugins_index + 1]
|
||||
if plugin_candidate == "__init__.py":
|
||||
plugin_name = "plugin"
|
||||
else:
|
||||
plugin_name = plugin_candidate
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if "main.py" in parts:
|
||||
# 已经到达程序的入口
|
||||
# 已经到达程序的入口,停止遍历
|
||||
break
|
||||
elif len(parts) != 1:
|
||||
# 已经超出程序范围
|
||||
# 已经超出程序范围,停止遍历
|
||||
break
|
||||
# 获取上一个帧
|
||||
try:
|
||||
frame = frame.f_back
|
||||
except AttributeError:
|
||||
break
|
||||
return caller_name or "log.py", plugin_name
|
||||
|
||||
@staticmethod
|
||||
def __setup_logger(log_file: str):
|
||||
"""
|
||||
设置日志
|
||||
log_file:日志文件相对路径
|
||||
初始化日志实例
|
||||
:param log_file:日志文件相对路径
|
||||
"""
|
||||
log_file_path = log_settings.LOG_PATH / log_file
|
||||
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -132,11 +164,8 @@ class LoggerManager:
|
||||
# 创建新实例
|
||||
_logger = logging.getLogger(log_file_path.stem)
|
||||
|
||||
if log_settings.DEBUG:
|
||||
_logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
loglevel = getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO)
|
||||
_logger.setLevel(loglevel)
|
||||
# 设置日志级别
|
||||
_logger.setLevel(LoggerManager.__get_log_level())
|
||||
|
||||
# 移除已有的 handler,避免重复添加
|
||||
for handler in _logger.handlers:
|
||||
@@ -162,6 +191,46 @@ class LoggerManager:
|
||||
|
||||
return _logger
|
||||
|
||||
def update_loggers(self):
|
||||
"""
|
||||
更新日志实例
|
||||
"""
|
||||
with LoggerManager._lock:
|
||||
for _logger in self._loggers.values():
|
||||
self.__update_logger_handlers(_logger)
|
||||
|
||||
@staticmethod
|
||||
def __update_logger_handlers(_logger: logging.Logger):
|
||||
"""
|
||||
更新 Logger 的 handler 配置
|
||||
:param _logger: 需要更新的 Logger 实例
|
||||
"""
|
||||
# 更新现有 handler
|
||||
for handler in _logger.handlers:
|
||||
try:
|
||||
if isinstance(handler, RotatingFileHandler):
|
||||
# 更新最大文件大小和备份数量
|
||||
handler.maxBytes = log_settings.LOG_MAX_FILE_SIZE_BYTES
|
||||
handler.backupCount = log_settings.LOG_BACKUP_COUNT
|
||||
# 更新日志文件输出格式
|
||||
file_formatter = CustomFormatter(log_settings.LOG_FILE_FORMAT)
|
||||
handler.setFormatter(file_formatter)
|
||||
elif isinstance(handler, logging.StreamHandler):
|
||||
# 更新控制台输出格式
|
||||
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
|
||||
handler.setFormatter(console_formatter)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update handler: {handler}. Error: {e}")
|
||||
# 更新日志级别
|
||||
_logger.setLevel(LoggerManager.__get_log_level())
|
||||
|
||||
@staticmethod
|
||||
def __get_log_level():
|
||||
"""
|
||||
获取当前日志级别
|
||||
"""
|
||||
return logging.DEBUG if log_settings.DEBUG else getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO)
|
||||
|
||||
def logger(self, method: str, msg: str, *args, **kwargs):
|
||||
"""
|
||||
获取模块的logger
|
||||
@@ -181,7 +250,7 @@ class LoggerManager:
|
||||
# 获取调用者的模块的logger
|
||||
_logger = self._loggers.get(logfile)
|
||||
if not _logger:
|
||||
_logger = self.__setup_logger(logfile)
|
||||
_logger = self.__setup_logger(log_file=logfile)
|
||||
self._loggers[logfile] = _logger
|
||||
# 调用logger的方法打印日志
|
||||
if hasattr(_logger, method):
|
||||
@@ -210,7 +279,7 @@ class LoggerManager:
|
||||
"""
|
||||
输出警告级别日志(兼容)
|
||||
"""
|
||||
self.logger("warning", msg, *args, **kwargs)
|
||||
self.warning(msg, *args, **kwargs)
|
||||
|
||||
def error(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,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 +29,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 = {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -179,7 +179,7 @@ class DoubanCache(metaclass=Singleton):
|
||||
return
|
||||
|
||||
with open(self._meta_path, 'wb') as f:
|
||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL)
|
||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa
|
||||
|
||||
def _random_sample(self, new_meta_data: dict) -> bool:
|
||||
"""
|
||||
|
||||
@@ -28,7 +28,7 @@ class DoubanScraper:
|
||||
# 电视剧元数据文件
|
||||
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
||||
if doc:
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from app.core.event import eventmanager
|
||||
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, MediaServerType
|
||||
|
||||
|
||||
@@ -73,8 +72,8 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
logger.info(f"Emby服务器 {name} 连接断开,尝试重连 ...")
|
||||
server.reconnect()
|
||||
|
||||
def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \
|
||||
-> Optional[AuthCredentials]:
|
||||
def user_authenticate(self, credentials: schemas.AuthCredentials, service_name: Optional[str] = None) \
|
||||
-> Optional[schemas.AuthCredentials]:
|
||||
"""
|
||||
使用Emby用户辅助完成用户认证
|
||||
:param credentials: 认证数据
|
||||
@@ -96,11 +95,11 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
# 触发认证拦截事件
|
||||
intercept_event = eventmanager.send_event(
|
||||
etype=ChainEventType.AuthIntercept,
|
||||
data=AuthInterceptCredentials(username=credentials.username, channel=self.get_name(),
|
||||
service=name, status="triggered")
|
||||
data=schemas.AuthInterceptCredentials(username=credentials.username, channel=self.get_name(),
|
||||
service=name, status="triggered")
|
||||
)
|
||||
if intercept_event and intercept_event.event_data:
|
||||
intercept_data: AuthInterceptCredentials = intercept_event.event_data
|
||||
intercept_data: schemas.AuthInterceptCredentials = intercept_event.event_data
|
||||
if intercept_data.cancel:
|
||||
continue
|
||||
token = server.authenticate(credentials.username, credentials.password)
|
||||
|
||||
@@ -391,7 +391,7 @@ class Emby:
|
||||
year: str = None,
|
||||
tmdb_id: int = None,
|
||||
season: int = None
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[Dict[int, list]]]]]:
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
|
||||
"""
|
||||
根据标题和年份和季,返回Emby中的剧集列表
|
||||
:param item_id: Emby中的ID
|
||||
|
||||
@@ -16,8 +16,7 @@ from app.helper.module import ModuleHelper
|
||||
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 TransferRenameEventData
|
||||
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage, TransferRenameEventData
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType, OtherModulesType
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
@@ -110,7 +109,7 @@ class FileManagerModule(_ModuleBase):
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
def support_transtype(self, storage: str) -> Optional[Dict[str, str]]:
|
||||
def support_transtype(self, storage: str) -> Optional[dict]:
|
||||
"""
|
||||
支持的整理方式
|
||||
"""
|
||||
@@ -369,10 +368,7 @@ class FileManagerModule(_ModuleBase):
|
||||
# 覆盖模式
|
||||
overwrite_mode = target_directory.overwrite_mode
|
||||
# 是否需要刮削
|
||||
if scrape is None:
|
||||
need_scrape = target_directory.scraping
|
||||
else:
|
||||
need_scrape = scrape
|
||||
need_scrape = scrape or target_directory.scraping
|
||||
# 目标存储类型
|
||||
if not target_storage:
|
||||
target_storage = target_directory.library_storage
|
||||
@@ -609,12 +605,12 @@ class FileManagerModule(_ModuleBase):
|
||||
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"|简体|简中|JPSC|sc_jp" \
|
||||
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"|繁体中[文字]|中[文字]繁体|繁体|JPTC|tc_jp" \
|
||||
r"|(?<![a-z0-9])big5(?![a-z0-9])"
|
||||
_eng_sub_re = r"[.\[(]eng[.\])]"
|
||||
|
||||
@@ -1185,7 +1181,7 @@ class FileManagerModule(_ModuleBase):
|
||||
# 集号
|
||||
"episode": meta.episode_seqs,
|
||||
# 季集 SxxExx
|
||||
"season_episode": "%s%s" % (meta.season, meta.episodes),
|
||||
"season_episode": "%s%s" % (meta.season, meta.episode),
|
||||
# 段/节
|
||||
"part": meta.part,
|
||||
# 剧集标题
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Union, Dict, Tuple
|
||||
from typing import Optional, List, Dict, Tuple
|
||||
|
||||
from app import schemas
|
||||
from app.helper.storage import StorageHelper
|
||||
|
||||
@@ -30,6 +30,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
|
||||
# 支持的整理方式
|
||||
transtype = {
|
||||
"copy": "复制",
|
||||
"move": "移动",
|
||||
}
|
||||
|
||||
@@ -71,7 +72,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
refresh_token = self.__auth_params.get("refreshToken")
|
||||
if refresh_token:
|
||||
try:
|
||||
self.aligo = Aligo(refresh_token=refresh_token, show=show_qrcode, use_aria2=self._has_aria2c,
|
||||
self.aligo = Aligo(refresh_token=refresh_token, show=show_qrcode, use_aria2=self._has_aria2c, # noqa
|
||||
name="MoviePilot V2", level=logging.ERROR, re_login=False)
|
||||
except Exception as err:
|
||||
logger.error(f"初始化阿里云盘失败:{str(err)}")
|
||||
@@ -327,7 +328,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
return None
|
||||
item = self.aligo.get_file_by_path(path=str(path))
|
||||
if item:
|
||||
return self.__get_fileitem(item, parent=path.parent)
|
||||
return self.__get_fileitem(item, parent=str(path.parent))
|
||||
return None
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
|
||||
@@ -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((Path(fileitem.path) / path.name).as_posix())
|
||||
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:
|
||||
@@ -569,7 +569,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
return
|
||||
|
||||
new_item = self.get_item(Path(fileitem.path) / path.name)
|
||||
if new_name and new_name != path.name:
|
||||
if new_item and new_name and new_name != path.name:
|
||||
if self.rename(new_item, new_name):
|
||||
return self.get_item(Path(new_item.path).with_name(new_name))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -366,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}")
|
||||
@@ -392,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:
|
||||
|
||||
@@ -282,6 +282,6 @@ class IndexerModule(_ModuleBase):
|
||||
leeching_size=site_obj.leeching_size,
|
||||
message_unread=site_obj.message_unread,
|
||||
message_unread_contents=site_obj.message_unread_contents or [],
|
||||
updated_at=datetime.now().strftime('%Y-%m-%d'),
|
||||
updated_day=datetime.now().strftime('%Y-%m-%d'),
|
||||
err_msg=site_obj.err_msg
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ class NexusPhpSiteUserInfo(SiteParserBase):
|
||||
message_text = message_labels[0].xpath("string(.)")
|
||||
|
||||
logger.debug(f"{self._site_name} 消息原始信息 {message_text}")
|
||||
message_unread_match = re.findall(r"[^Date](信息箱\s*|\(|你有\xa0)(\d+)", message_text)
|
||||
message_unread_match = re.findall(r"[^Date](信息箱\s*|\((?![^)]*:)|你有\xa0)(\d+)", message_text)
|
||||
|
||||
if message_unread_match and len(message_unread_match[-1]) == 2:
|
||||
self.message_unread = StringUtils.str_int(message_unread_match[-1][1])
|
||||
|
||||
@@ -672,7 +672,7 @@ class TorrentSpider:
|
||||
elif method_name == "appendleft":
|
||||
text = f"{args}{text}"
|
||||
elif method_name == "querystring":
|
||||
parsed_url = urlparse(text)
|
||||
parsed_url = urlparse(str(text))
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
param_value = query_params.get(args)
|
||||
text = param_value[0] if param_value else ''
|
||||
|
||||
@@ -6,7 +6,7 @@ from app.core.event import eventmanager
|
||||
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 import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from app.core.event import eventmanager
|
||||
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 import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType
|
||||
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None,
|
||||
downloader: str = None) -> Optional[Tuple[Optional[str], Optional[str], str]]:
|
||||
downloader: str = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
:param content: 种子文件地址或者磁力链接
|
||||
@@ -88,7 +88,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
:param episodes: 需要下载的集数
|
||||
:param category: 分类
|
||||
:param downloader: 下载器
|
||||
:return: 种子Hash,错误信息
|
||||
:return: 下载器名称、种子Hash、种子文件布局、错误原因
|
||||
"""
|
||||
|
||||
def __get_torrent_info() -> Tuple[str, int]:
|
||||
@@ -106,10 +106,10 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
return "", 0
|
||||
|
||||
if not content:
|
||||
return None, None, "下载内容为空"
|
||||
return None, None, None, "下载内容为空"
|
||||
if isinstance(content, Path) and not content.exists():
|
||||
logger.error(f"种子文件不存在:{content}")
|
||||
return None, None, f"种子文件不存在:{content}"
|
||||
return None, None, None, f"种子文件不存在:{content}"
|
||||
|
||||
# 获取下载器
|
||||
server: Qbittorrent = self.get_instance(downloader)
|
||||
@@ -134,15 +134,19 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
category=category,
|
||||
ignore_category_check=False
|
||||
)
|
||||
|
||||
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
|
||||
torrent_layout = server.get_content_layout()
|
||||
|
||||
if not state:
|
||||
# 读取种子的名称
|
||||
torrent_name, torrent_size = __get_torrent_info()
|
||||
if not torrent_name:
|
||||
return None, None, f"添加种子任务失败:无法读取种子文件"
|
||||
return None, None, None, f"添加种子任务失败:无法读取种子文件"
|
||||
# 查询所有下载器的种子
|
||||
torrents, error = server.get_torrents()
|
||||
if error:
|
||||
return None, None, "无法连接qbittorrent下载器"
|
||||
return None, None, None, "无法连接qbittorrent下载器"
|
||||
if torrents:
|
||||
for torrent in torrents:
|
||||
# 名称与大小相等则认为是同一个种子
|
||||
@@ -156,19 +160,19 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
if settings.TORRENT_TAG and settings.TORRENT_TAG not in torrent_tags:
|
||||
logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}")
|
||||
server.set_torrents_tag(ids=torrent_hash, tags=[settings.TORRENT_TAG])
|
||||
return downloader or self.get_default_config_name(), torrent_hash, f"下载任务已存在"
|
||||
return None, None, f"添加种子任务失败:{content}"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在"
|
||||
return None, None, None, f"添加种子任务失败:{content}"
|
||||
else:
|
||||
# 获取种子Hash
|
||||
torrent_hash = server.get_torrent_id_by_tag(tags=tag)
|
||||
if not torrent_hash:
|
||||
return None, None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}"
|
||||
return None, None, None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}"
|
||||
else:
|
||||
if is_paused:
|
||||
# 种子文件
|
||||
torrent_files = server.get_files(torrent_hash)
|
||||
if not torrent_files:
|
||||
return downloader or self.get_default_config_name(), torrent_hash, "获取种子文件失败,下载任务可能在暂停状态"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "获取种子文件失败,下载任务可能在暂停状态"
|
||||
|
||||
# 不需要的文件ID
|
||||
file_ids = []
|
||||
@@ -193,11 +197,11 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
server.torrents_set_force_start(torrent_hash)
|
||||
else:
|
||||
server.start_torrents(torrent_hash)
|
||||
return downloader or self.get_default_config_name(), torrent_hash, f"添加下载成功,已选择集数:{sucess_epidised}"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"添加下载成功,已选择集数:{sucess_epidised}"
|
||||
else:
|
||||
if server.is_force_resume():
|
||||
server.torrents_set_force_start(torrent_hash)
|
||||
return downloader or self.get_default_config_name(), torrent_hash, "添加下载成功"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载成功"
|
||||
|
||||
def list_torrents(self, status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None,
|
||||
|
||||
@@ -448,3 +448,14 @@ class Qbittorrent:
|
||||
except Exception as err:
|
||||
logger.error(f"修改tracker出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def get_content_layout(self) -> Optional[str]:
|
||||
"""
|
||||
获取内容布局
|
||||
"""
|
||||
if not self.qbc:
|
||||
return None
|
||||
# 获取下载器全局设置
|
||||
application = self.qbc.application.preferences
|
||||
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
|
||||
return application.get("torrent_content_layout", "Original")
|
||||
|
||||
@@ -7,8 +7,7 @@ from app.core.event import eventmanager
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase, _MessageBase
|
||||
from app.modules.telegram.telegram import Telegram
|
||||
from app.schemas import MessageChannel, CommingMessage, Notification
|
||||
from app.schemas.event import CommandRegisterEventData
|
||||
from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData
|
||||
from app.schemas.types import ModuleType, ChainEventType
|
||||
from app.utils.structures import DictUtils
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class TmdbScraper:
|
||||
# 电视剧元数据文件
|
||||
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
||||
if doc:
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
"""
|
||||
Simple-to-use Python interface to The TVDB's API (thetvdb.com)
|
||||
"""
|
||||
@@ -6,19 +5,20 @@
|
||||
__author__ = "dbr/Ben"
|
||||
__version__ = "3.1.0"
|
||||
|
||||
import sys
|
||||
import getpass
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import getpass
|
||||
import tempfile
|
||||
import warnings
|
||||
import logging
|
||||
import hashlib
|
||||
from typing import Optional, Union
|
||||
|
||||
import requests
|
||||
import requests_cache
|
||||
from requests_cache.backends.base import _to_bytes, _DEFAULT_HEADERS
|
||||
from requests_cache.backends.base import _to_bytes, _DEFAULT_HEADERS # noqa
|
||||
|
||||
IS_PY2 = sys.version_info[0] == 2
|
||||
|
||||
@@ -176,7 +176,8 @@ class ConsoleUI(BaseUI):
|
||||
"""Interactively allows the user to select a show from a console based UI
|
||||
"""
|
||||
|
||||
def _displaySeries(self, allSeries, limit=6):
|
||||
@staticmethod
|
||||
def _displaySeries(allSeries, limit: Optional[int] = 6):
|
||||
"""Helper function, lists series with corresponding ID
|
||||
"""
|
||||
if limit is not None:
|
||||
@@ -267,6 +268,7 @@ class ShowContainer(dict):
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._stack = []
|
||||
self._lastgc = time.time()
|
||||
|
||||
@@ -336,42 +338,6 @@ class Show(dict):
|
||||
|
||||
Search terms are converted to lower case (unicode) strings.
|
||||
|
||||
# Examples
|
||||
|
||||
These examples assume t is an instance of Tvdb():
|
||||
|
||||
>>> t = Tvdb()
|
||||
>>>
|
||||
|
||||
To search for all episodes of Scrubs with a bit of data
|
||||
containing "my first day":
|
||||
|
||||
>>> t['Scrubs'].search("my first day")
|
||||
[<Episode 01x01 - u'My First Day'>]
|
||||
>>>
|
||||
|
||||
Search for "My Name Is Earl" episode named "Faked His Own Death":
|
||||
|
||||
>>> t['My Name Is Earl'].search('Faked My Own Death', key='episodeName')
|
||||
[<Episode 01x04 - u'Faked My Own Death'>]
|
||||
>>>
|
||||
|
||||
To search Scrubs for all episodes with "mentor" in the episode name:
|
||||
|
||||
>>> t['scrubs'].search('mentor', key='episodeName')
|
||||
[<Episode 01x02 - u'My Mentor'>, <Episode 03x15 - u'My Tormented Mentor'>]
|
||||
>>>
|
||||
|
||||
# Using search results
|
||||
|
||||
>>> results = t['Scrubs'].search("my first")
|
||||
>>> print results[0]['episodeName']
|
||||
My First Day
|
||||
>>> for x in results: print x['episodeName']
|
||||
My First Day
|
||||
My First Step
|
||||
My First Kill
|
||||
>>>
|
||||
"""
|
||||
results = []
|
||||
for cur_season in self.values():
|
||||
@@ -386,6 +352,7 @@ class Season(dict):
|
||||
def __init__(self, show=None):
|
||||
"""The show attribute points to the parent show
|
||||
"""
|
||||
super().__init__()
|
||||
self.show = show
|
||||
|
||||
def __repr__(self):
|
||||
@@ -420,6 +387,7 @@ class Episode(dict):
|
||||
def __init__(self, season=None):
|
||||
"""The season attribute points to the parent season
|
||||
"""
|
||||
super().__init__()
|
||||
self.season = season
|
||||
|
||||
def __repr__(self):
|
||||
@@ -540,7 +508,7 @@ class Tvdb:
|
||||
self,
|
||||
interactive=False,
|
||||
select_first=False,
|
||||
cache=True,
|
||||
cache: Union[str, bool, requests.Session] = True,
|
||||
banners=False,
|
||||
actors=False,
|
||||
custom_ui=None,
|
||||
@@ -690,7 +658,7 @@ class Tvdb:
|
||||
LOG.debug("Using specified requests.Session")
|
||||
self.session = cache
|
||||
try:
|
||||
self.session.get
|
||||
self.session.get # noqa
|
||||
except AttributeError:
|
||||
raise ValueError(
|
||||
(
|
||||
@@ -776,7 +744,7 @@ class Tvdb:
|
||||
cache_key = self.session.cache.create_key(
|
||||
fake_session_for_key.prepare_request(requests.Request('GET', url))
|
||||
)
|
||||
except Exception:
|
||||
except Exception: # noqa
|
||||
# FIXME: Can this just check for hasattr(self.session, "cache") instead?
|
||||
pass
|
||||
|
||||
@@ -956,6 +924,7 @@ class Tvdb:
|
||||
banners_resp = self._getetsrc(self.config['url_seriesBanner'] % sid)
|
||||
banners = {}
|
||||
for cur_banner in banners_resp.keys():
|
||||
btype = None
|
||||
banners_info = self._getetsrc(self.config['url_seriesBannerInfo'] % (sid, cur_banner))
|
||||
for banner_info in banners_info:
|
||||
bid = banner_info.get('id')
|
||||
@@ -981,32 +950,14 @@ class Tvdb:
|
||||
LOG.debug("Transforming %s to %s" % (k, new_key))
|
||||
new_url = self.config['url_artworkPrefix'] % v
|
||||
banners[btype][btype2][bid][new_key] = new_url
|
||||
|
||||
banners[btype]['raw'] = banners_info
|
||||
self._setShowData(sid, "_banners", banners)
|
||||
if btype:
|
||||
banners[btype]['raw'] = banners_info
|
||||
self._setShowData(sid, "_banners", banners)
|
||||
|
||||
def _parseActors(self, sid):
|
||||
"""Parsers actors XML, from
|
||||
http://thetvdb.com/api/[APIKEY]/series/[SERIES ID]/actors.xml
|
||||
|
||||
Actors are retrieved using t['show name]['_actors'], for example:
|
||||
|
||||
>>> t = Tvdb(actors = True)
|
||||
>>> actors = t['scrubs']['_actors']
|
||||
>>> type(actors)
|
||||
<class 'tvdb_api.Actors'>
|
||||
>>> type(actors[0])
|
||||
<class 'tvdb_api.Actor'>
|
||||
>>> actors[0]
|
||||
<Actor u'John C. McGinley'>
|
||||
>>> sorted(actors[0].keys())
|
||||
[u'id', u'image', u'imageAdded', u'imageAuthor', u'lastUpdated', u'name', u'role',
|
||||
u'seriesId', u'sortOrder']
|
||||
>>> actors[0]['name']
|
||||
u'John C. McGinley'
|
||||
>>> actors[0]['image']
|
||||
u'http://thetvdb.com/banners/actors/43638.jpg'
|
||||
|
||||
Any key starting with an underscore has been processed (not the raw
|
||||
data from the XML)
|
||||
"""
|
||||
|
||||
@@ -80,7 +80,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None,
|
||||
downloader: str = None) -> Optional[Tuple[Optional[str], Optional[str], str]]:
|
||||
downloader: str = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
:param content: 种子文件地址或者磁力链接
|
||||
@@ -89,7 +89,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
:param episodes: 需要下载的集数
|
||||
:param category: 分类,TR中未使用
|
||||
:param downloader: 下载器
|
||||
:return: 下载器名称、种子Hash、错误原因
|
||||
:return: 下载器名称、种子Hash、种子文件布局、错误原因
|
||||
"""
|
||||
|
||||
def __get_torrent_info() -> Tuple[str, int]:
|
||||
@@ -107,9 +107,9 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
return "", 0
|
||||
|
||||
if not content:
|
||||
return None, None, "下载内容为空"
|
||||
return None, None, None, "下载内容为空"
|
||||
if isinstance(content, Path) and not content.exists():
|
||||
return None, None, f"种子文件不存在:{content}"
|
||||
return None, None, None, f"种子文件不存在:{content}"
|
||||
|
||||
# 获取下载器
|
||||
server: Transmission = self.get_instance(downloader)
|
||||
@@ -131,15 +131,18 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
labels=labels,
|
||||
cookie=cookie
|
||||
)
|
||||
# TR 始终使用原始种子布局, 返回"Original"
|
||||
torrent_layout = "Original"
|
||||
|
||||
if not torrent:
|
||||
# 读取种子的名称
|
||||
torrent_name, torrent_size = __get_torrent_info()
|
||||
if not torrent_name:
|
||||
return None, None, f"添加种子任务失败:无法读取种子文件"
|
||||
return None, None, None, f"添加种子任务失败:无法读取种子文件"
|
||||
# 查询所有下载器的种子
|
||||
torrents, error = server.get_torrents()
|
||||
if error:
|
||||
return None, None, "无法连接transmission下载器"
|
||||
return None, None, None, "无法连接transmission下载器"
|
||||
if torrents:
|
||||
for torrent in torrents:
|
||||
# 名称与大小相等则认为是同一个种子
|
||||
@@ -158,15 +161,15 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
if settings.TORRENT_TAG and settings.TORRENT_TAG not in labels:
|
||||
labels.append(settings.TORRENT_TAG)
|
||||
server.set_torrent_tag(ids=torrent_hash, tags=labels)
|
||||
return downloader or self.get_default_config_name(), torrent_hash, f"下载任务已存在"
|
||||
return None, None, f"添加种子任务失败:{content}"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在"
|
||||
return None, None, None, f"添加种子任务失败:{content}"
|
||||
else:
|
||||
torrent_hash = torrent.hashString
|
||||
if is_paused:
|
||||
# 选择文件
|
||||
torrent_files = server.get_files(torrent_hash)
|
||||
if not torrent_files:
|
||||
return downloader or self.get_default_config_name(), torrent_hash, "获取种子文件失败,下载任务可能在暂停状态"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "获取种子文件失败,下载任务可能在暂停状态"
|
||||
# 需要的文件信息
|
||||
file_ids = []
|
||||
unwanted_file_ids = []
|
||||
@@ -187,9 +190,9 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
server.set_unwanted_files(torrent_hash, unwanted_file_ids)
|
||||
# 开始任务
|
||||
server.start_torrents(torrent_hash)
|
||||
return downloader or self.get_default_config_name(), torrent_hash, "添加下载任务成功"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载任务成功"
|
||||
else:
|
||||
return downloader or self.get_default_config_name(), torrent_hash, "添加下载任务成功"
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载任务成功"
|
||||
|
||||
def list_torrents(self, status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from typing import Optional, Union, Tuple, List
|
||||
from typing import Optional, Union, Tuple, List, Literal
|
||||
|
||||
import transmission_rpc
|
||||
from transmission_rpc import Client, Torrent, File
|
||||
from transmission_rpc.session import SessionStats, Session
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
|
||||
class Transmission:
|
||||
_protocol: Literal["http", "https"] = "http"
|
||||
_host: str = None
|
||||
_port: int = None
|
||||
_username: str = None
|
||||
@@ -28,9 +29,14 @@ class Transmission:
|
||||
若不设置参数,则创建配置文件设置的下载器
|
||||
"""
|
||||
if host and port:
|
||||
self._host, self._port = host, port
|
||||
self._protocol, self._host, self._port = kwargs.get("protocol", self._protocol), host, port
|
||||
elif host:
|
||||
self._host, self._port = StringUtils.get_domain_address(address=host, prefix=False)
|
||||
result = UrlUtils.parse_url_params(url=host)
|
||||
if result:
|
||||
self._protocol, self._host, self._port, path = result
|
||||
else:
|
||||
logger.error("Transmission配置不正确!")
|
||||
return
|
||||
else:
|
||||
logger.error("Transmission配置不完整!")
|
||||
return
|
||||
@@ -46,8 +52,9 @@ class Transmission:
|
||||
"""
|
||||
try:
|
||||
# 登录
|
||||
logger.info(f"正在连接 transmission:{self._host}:{self._port}")
|
||||
trt = transmission_rpc.Client(host=self._host,
|
||||
logger.info(f"正在连接 transmission:{self._protocol}://{self._host}:{self._port}")
|
||||
trt = transmission_rpc.Client(protocol=self._protocol,
|
||||
host=self._host,
|
||||
port=self._port,
|
||||
username=self._username,
|
||||
password=self._password,
|
||||
@@ -126,7 +133,7 @@ class Transmission:
|
||||
return None
|
||||
try:
|
||||
torrents, error = self.get_torrents(ids=ids,
|
||||
status=["downloading", "download_pending", "stopped"],
|
||||
status=["downloading", "download_pending"],
|
||||
tags=tags)
|
||||
return None if error else torrents or []
|
||||
except Exception as err:
|
||||
|
||||
@@ -8,8 +8,7 @@ from app.log import logger
|
||||
from app.modules import _ModuleBase, _MessageBase
|
||||
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
from app.modules.wechat.wechat import WeChat
|
||||
from app.schemas import MessageChannel, CommingMessage, Notification
|
||||
from app.schemas.event import CommandRegisterEventData
|
||||
from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData
|
||||
from app.schemas.types import ModuleType, ChainEventType
|
||||
from app.utils.dom import DomUtils
|
||||
from app.utils.structures import DictUtils
|
||||
|
||||
421
app/monitor.py
421
app/monitor.py
@@ -1,37 +1,26 @@
|
||||
import datetime
|
||||
import platform
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from cachetools import TTLCache
|
||||
from watchdog.events import FileSystemEventHandler, FileSystemMovedEvent, FileSystemEvent
|
||||
from watchdog.observers.polling import PollingObserver
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.storage import StorageChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import EventManager
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.log import logger
|
||||
from app.schemas import FileItem, TransferInfo, Notification
|
||||
from app.schemas.types import SystemConfigKey, MediaType, NotificationType, EventType
|
||||
from app.schemas import FileItem
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
lock = Lock()
|
||||
snapshot_lock = Lock()
|
||||
@@ -52,12 +41,12 @@ class FileMonitorHandler(FileSystemEventHandler):
|
||||
self.callback = callback
|
||||
|
||||
def on_created(self, event: FileSystemEvent):
|
||||
self.callback.event_handler(event=event, text="创建",
|
||||
mon_path=self._watch_path, event_path=Path(event.src_path))
|
||||
self.callback.event_handler(event=event, text="创建", event_path=event.src_path,
|
||||
file_size=Path(event.src_path).stat().st_size)
|
||||
|
||||
def on_moved(self, event: FileSystemMovedEvent):
|
||||
self.callback.event_handler(event=event, text="移动",
|
||||
mon_path=self._watch_path, event_path=Path(event.dest_path))
|
||||
self.callback.event_handler(event=event, text="移动", event_path=event.dest_path,
|
||||
file_size=Path(event.dest_path).stat().st_size)
|
||||
|
||||
|
||||
class Monitor(metaclass=Singleton):
|
||||
@@ -80,29 +69,12 @@ class Monitor(metaclass=Singleton):
|
||||
# 存储过照间隔(分钟)
|
||||
_snapshot_interval = 5
|
||||
|
||||
# 待整理任务队列
|
||||
_queue = Queue()
|
||||
|
||||
# 文件整理线程
|
||||
_transfer_thread = None
|
||||
|
||||
# 文件整理间隔(秒)
|
||||
_transfer_interval = 60
|
||||
|
||||
# 消息汇总
|
||||
_msg_medias = {}
|
||||
|
||||
# 消息汇总间隔(秒)
|
||||
_msg_interval = 60
|
||||
# TTL缓存,10秒钟有效
|
||||
_cache = TTLCache(maxsize=1024, ttl=10)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.chain = MonitorChain()
|
||||
self.transferhis = TransferHistoryOper()
|
||||
self.transferchain = TransferChain()
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.mediaChain = MediaChain()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.storagechain = StorageChain()
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
self.systemmessage = MessageHelper()
|
||||
@@ -120,10 +92,6 @@ class Monitor(metaclass=Singleton):
|
||||
# 停止现有任务
|
||||
self.stop()
|
||||
|
||||
# 启动文件整理线程
|
||||
self._transfer_thread = threading.Thread(target=self.__start_transfer, daemon=True)
|
||||
self._transfer_thread.start()
|
||||
|
||||
# 读取目录配置
|
||||
monitor_dirs = self.directoryhelper.get_download_dirs()
|
||||
if not monitor_dirs:
|
||||
@@ -183,9 +151,6 @@ class Monitor(metaclass=Singleton):
|
||||
'storage': mon_dir.storage,
|
||||
'mon_path': mon_path
|
||||
})
|
||||
|
||||
# 追加入库消息统一发送服务
|
||||
self._scheduler.add_job(self.__send_msg, trigger='interval', seconds=15)
|
||||
# 启动定时服务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
@@ -212,16 +177,6 @@ class Monitor(metaclass=Singleton):
|
||||
logger.warn(f"导入模块错误:{error},将使用 PollingObserver 监控目录")
|
||||
return PollingObserver()
|
||||
|
||||
def put_to_queue(self, storage: str, filepath: Path, mon_path: Path):
|
||||
"""
|
||||
添加到待整理队列
|
||||
"""
|
||||
self._queue.put({
|
||||
"storage": storage,
|
||||
"filepath": filepath,
|
||||
"mon_path": mon_path
|
||||
})
|
||||
|
||||
def polling_observer(self, storage: str, mon_path: Path):
|
||||
"""
|
||||
轮询监控
|
||||
@@ -237,48 +192,42 @@ class Monitor(metaclass=Singleton):
|
||||
new_files = new_snapshot.keys() - old_snapshot.keys()
|
||||
for new_file in new_files:
|
||||
# 添加到待整理队列
|
||||
self.put_to_queue(storage=storage, filepath=Path(new_file), mon_path=mon_path)
|
||||
self.__handle_file(storage=storage, event_path=Path(new_file),
|
||||
file_size=new_snapshot.get(new_file))
|
||||
# 更新快照
|
||||
self._storage_snapshot[storage] = new_snapshot
|
||||
|
||||
def event_handler(self, event, mon_path: Path, text: str, event_path: Path):
|
||||
def event_handler(self, event, text: str, event_path: str, file_size: float = None):
|
||||
"""
|
||||
处理文件变化
|
||||
:param event: 事件
|
||||
:param mon_path: 监控目录
|
||||
:param text: 事件描述
|
||||
:param event_path: 事件文件路径
|
||||
:param file_size: 文件大小
|
||||
"""
|
||||
if not event.is_directory:
|
||||
# 文件发生变化
|
||||
logger.debug(f"文件 {event_path} 发生了 {text}")
|
||||
# 添加到待整理队列
|
||||
self.put_to_queue(storage="local", filepath=event_path, mon_path=mon_path)
|
||||
# 整理文件
|
||||
self.__handle_file(storage="local", event_path=Path(event_path), file_size=file_size)
|
||||
|
||||
def __start_transfer(self):
|
||||
"""
|
||||
整理队列中的文件
|
||||
"""
|
||||
while not self._event.is_set():
|
||||
try:
|
||||
item = self._queue.get(timeout=self._transfer_interval)
|
||||
if item:
|
||||
self.__handle_file(storage=item.get("storage"), event_path=item.get("filepath"))
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"整理队列处理出现错误:{e}")
|
||||
|
||||
def __handle_file(self, storage: str, event_path: Path):
|
||||
def __handle_file(self, storage: str, event_path: Path, file_size: float = None):
|
||||
"""
|
||||
整理一个文件
|
||||
:param storage: 存储
|
||||
:param event_path: 事件文件路径
|
||||
:param file_size: 文件大小
|
||||
"""
|
||||
|
||||
def __get_bluray_dir(_path: Path):
|
||||
def __is_bluray_sub(_path: Path) -> bool:
|
||||
"""
|
||||
获取BDMV目录的上级目录
|
||||
判断是否蓝光原盘目录内的子目录或文件
|
||||
"""
|
||||
return True if re.search(r"BDMV[/\\]STREAM", str(_path), re.IGNORECASE) else False
|
||||
|
||||
def __get_bluray_dir(_path: Path) -> Optional[Path]:
|
||||
"""
|
||||
获取蓝光原盘BDMV目录的上级目录
|
||||
"""
|
||||
for p in _path.parents:
|
||||
if p.name == "BDMV":
|
||||
@@ -287,311 +236,33 @@ class Monitor(metaclass=Singleton):
|
||||
|
||||
# 全程加锁
|
||||
with lock:
|
||||
# 蓝光原盘文件处理
|
||||
if __is_bluray_sub(event_path):
|
||||
event_path = __get_bluray_dir(event_path)
|
||||
if not event_path:
|
||||
return
|
||||
|
||||
# TTL缓存控重
|
||||
if self._cache.get(str(event_path)):
|
||||
return
|
||||
self._cache[str(event_path)] = True
|
||||
|
||||
try:
|
||||
# 回收站及隐藏的文件不处理
|
||||
if str(event_path).find('/@Recycle/') != -1 \
|
||||
or str(event_path).find('/#recycle/') != -1 \
|
||||
or str(event_path).find('/.') != -1 \
|
||||
or str(event_path).find('/@eaDir') != -1:
|
||||
logger.debug(f"{event_path} 是回收站或隐藏的文件")
|
||||
return
|
||||
|
||||
# 不是媒体文件不处理
|
||||
if event_path.suffix.lower() not in self.all_exts:
|
||||
logger.debug(f"{event_path} 不是媒体文件")
|
||||
return
|
||||
|
||||
# 整理屏蔽词不处理
|
||||
transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
|
||||
if transfer_exclude_words:
|
||||
for keyword in transfer_exclude_words:
|
||||
if not keyword:
|
||||
continue
|
||||
if keyword and re.search(r"%s" % keyword, str(event_path), re.IGNORECASE):
|
||||
logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理")
|
||||
return
|
||||
|
||||
# 判断是不是蓝光目录
|
||||
bluray_flag = False
|
||||
if re.search(r"BDMV[/\\]STREAM", str(event_path), re.IGNORECASE):
|
||||
bluray_flag = True
|
||||
# 截取BDMV前面的路径
|
||||
event_path = __get_bluray_dir(event_path)
|
||||
logger.info(f"{event_path} 是蓝光原盘目录,更正文件路径为:{event_path}")
|
||||
|
||||
# 查询历史记录,已转移的不处理
|
||||
if self.transferhis.get_by_src(str(event_path), storage=storage):
|
||||
logger.info(f"{event_path} 已经整理过了")
|
||||
return
|
||||
|
||||
# 元数据
|
||||
file_meta = MetaInfoPath(event_path)
|
||||
if not file_meta.name:
|
||||
logger.error(f"{event_path.name} 无法识别有效信息")
|
||||
return
|
||||
|
||||
# 根据父路径获取下载历史
|
||||
download_history = None
|
||||
if bluray_flag:
|
||||
# 蓝光原盘,按目录名查询
|
||||
download_history = self.downloadhis.get_by_path(str(event_path))
|
||||
else:
|
||||
# 按文件全路径查询
|
||||
download_file = self.downloadhis.get_file_by_fullpath(str(event_path))
|
||||
if download_file:
|
||||
download_history = self.downloadhis.get_by_hash(download_file.download_hash)
|
||||
|
||||
# 获取下载Hash
|
||||
download_hash = None
|
||||
if download_history:
|
||||
download_hash = download_history.download_hash
|
||||
|
||||
# 识别媒体信息
|
||||
if download_history and (download_history.tmdbid or download_history.doubanid):
|
||||
# 下载记录中已存在识别信息
|
||||
mediainfo: MediaInfo = self.mediaChain.recognize_media(mtype=MediaType(download_history.type),
|
||||
tmdbid=download_history.tmdbid,
|
||||
doubanid=download_history.doubanid)
|
||||
if mediainfo:
|
||||
# 更新自定义媒体类别
|
||||
if download_history.media_category:
|
||||
mediainfo.category = download_history.media_category
|
||||
|
||||
else:
|
||||
mediainfo: MediaInfo = self.mediaChain.recognize_by_meta(file_meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{file_meta.name}')
|
||||
# 新增转移失败历史记录
|
||||
his = self.transferhis.add_fail(
|
||||
fileitem=FileItem(
|
||||
storage=storage,
|
||||
type="file",
|
||||
path=str(event_path),
|
||||
name=event_path.name,
|
||||
basename=event_path.stem,
|
||||
extension=event_path.suffix[1:],
|
||||
),
|
||||
mode='',
|
||||
meta=file_meta,
|
||||
download_hash=download_hash
|
||||
# 开始整理
|
||||
self.transferchain.do_transfer(
|
||||
fileitem=FileItem(
|
||||
storage=storage,
|
||||
path=str(event_path).replace("\\", "/"),
|
||||
type="file",
|
||||
name=event_path.name,
|
||||
basename=event_path.stem,
|
||||
extension=event_path.suffix[1:],
|
||||
size=file_size
|
||||
)
|
||||
self.chain.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{event_path.name} 未识别到媒体信息,无法入库!",
|
||||
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。",
|
||||
link=settings.MP_DOMAIN('#/history')
|
||||
))
|
||||
return
|
||||
|
||||
# 查询转移目的目录
|
||||
dir_info = self.directoryhelper.get_dir(mediainfo, storage=storage, src_path=event_path)
|
||||
if not dir_info:
|
||||
logger.warn(f"{event_path.name} 未找到对应的目标目录")
|
||||
return
|
||||
|
||||
# 查找这个文件项
|
||||
file_item = self.storagechain.get_file_item(storage=storage, path=event_path)
|
||||
if not file_item:
|
||||
logger.warn(f"{event_path.name} 未找到对应的文件")
|
||||
return
|
||||
|
||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||
if not settings.SCRAP_FOLLOW_TMDB:
|
||||
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
|
||||
mtype=mediainfo.type.value)
|
||||
if transfer_history:
|
||||
mediainfo.title = transfer_history.title
|
||||
logger.info(f"{event_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}")
|
||||
|
||||
# 更新媒体图片
|
||||
self.chain.obtain_images(mediainfo=mediainfo)
|
||||
|
||||
# 获取集数据
|
||||
if mediainfo.type == MediaType.TV:
|
||||
episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id,
|
||||
season=file_meta.begin_season or 1)
|
||||
else:
|
||||
episodes_info = None
|
||||
|
||||
# 转移
|
||||
transferinfo: TransferInfo = self.chain.transfer(fileitem=file_item,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
target_directory=dir_info,
|
||||
episodes_info=episodes_info)
|
||||
|
||||
if not transferinfo:
|
||||
logger.error("文件转移模块运行失败")
|
||||
return
|
||||
|
||||
if not transferinfo.success:
|
||||
# 转移失败
|
||||
logger.warn(f"{event_path.name} 入库失败:{transferinfo.message}")
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
fileitem=file_item,
|
||||
mode=transferinfo.transfer_type if transferinfo else '',
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
transferinfo=transferinfo
|
||||
)
|
||||
# 发送失败消息
|
||||
self.chain.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{mediainfo.title_year} {file_meta.season_episode} 入库失败!",
|
||||
text=f"原因:{transferinfo.message or '未知'}",
|
||||
image=mediainfo.get_message_image(),
|
||||
link=settings.MP_DOMAIN('#/history')
|
||||
))
|
||||
return
|
||||
|
||||
# 转移成功
|
||||
logger.info(f"{event_path.name} 入库成功:{transferinfo.target_diritem.path}")
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_success(
|
||||
fileitem=file_item,
|
||||
mode=transferinfo.transfer_type if transferinfo else '',
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
transferinfo=transferinfo
|
||||
)
|
||||
|
||||
# 汇总刮削
|
||||
if transferinfo.need_scrape:
|
||||
self.mediaChain.scrape_metadata(fileitem=transferinfo.target_diritem,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo)
|
||||
|
||||
# 广播事件
|
||||
EventManager().send_event(EventType.TransferComplete, {
|
||||
'fileitem': file_item,
|
||||
'meta': file_meta,
|
||||
'mediainfo': mediainfo,
|
||||
'transferinfo': transferinfo
|
||||
})
|
||||
|
||||
# 发送消息汇总
|
||||
if transferinfo.need_notify:
|
||||
self.__collect_msg_medias(mediainfo=mediainfo, file_meta=file_meta, transferinfo=transferinfo)
|
||||
|
||||
# 移动模式删除空目录
|
||||
if transferinfo.transfer_type in ["move"]:
|
||||
self.storagechain.delete_media_file(file_item, delete_self=False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc()))
|
||||
|
||||
def __collect_msg_medias(self, mediainfo: MediaInfo, file_meta: MetaInfoPath, transferinfo: TransferInfo):
|
||||
"""
|
||||
收集媒体处理完的消息
|
||||
"""
|
||||
media_list = self._msg_medias.get(mediainfo.title_year + " " + file_meta.season) or {}
|
||||
if media_list:
|
||||
media_files = media_list.get("files") or []
|
||||
if media_files:
|
||||
file_exists = False
|
||||
for file in media_files:
|
||||
if str(transferinfo.fileitem.path) == file.get("path"):
|
||||
file_exists = True
|
||||
break
|
||||
if not file_exists:
|
||||
media_files.append({
|
||||
"path": str(transferinfo.fileitem.path),
|
||||
"mediainfo": mediainfo,
|
||||
"file_meta": file_meta,
|
||||
"transferinfo": transferinfo
|
||||
})
|
||||
else:
|
||||
media_files = [
|
||||
{
|
||||
"path": str(transferinfo.fileitem.path),
|
||||
"mediainfo": mediainfo,
|
||||
"file_meta": file_meta,
|
||||
"transferinfo": transferinfo
|
||||
}
|
||||
]
|
||||
media_list = {
|
||||
"files": media_files,
|
||||
"time": datetime.datetime.now()
|
||||
}
|
||||
else:
|
||||
media_list = {
|
||||
"files": [
|
||||
{
|
||||
"path": str(transferinfo.fileitem.path),
|
||||
"mediainfo": mediainfo,
|
||||
"file_meta": file_meta,
|
||||
"transferinfo": transferinfo
|
||||
}
|
||||
],
|
||||
"time": datetime.datetime.now()
|
||||
}
|
||||
self._msg_medias[mediainfo.title_year + " " + file_meta.season] = media_list
|
||||
|
||||
def __send_msg(self):
|
||||
"""
|
||||
定时检查是否有媒体处理完,发送统一消息
|
||||
"""
|
||||
if not self._msg_medias or not self._msg_medias.keys():
|
||||
return
|
||||
|
||||
# 遍历检查是否已刮削完,发送消息
|
||||
for medis_title_year_season in list(self._msg_medias.keys()):
|
||||
media_list = self._msg_medias.get(medis_title_year_season)
|
||||
logger.info(f"开始处理媒体 {medis_title_year_season} 消息")
|
||||
|
||||
if not media_list:
|
||||
continue
|
||||
|
||||
# 获取最后更新时间
|
||||
last_update_time = media_list.get("time")
|
||||
media_files = media_list.get("files")
|
||||
if not last_update_time or not media_files:
|
||||
continue
|
||||
|
||||
transferinfo = media_files[0].get("transferinfo")
|
||||
file_meta = media_files[0].get("file_meta")
|
||||
mediainfo = media_files[0].get("mediainfo")
|
||||
# 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息
|
||||
if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._msg_interval) \
|
||||
or mediainfo.type == MediaType.MOVIE:
|
||||
|
||||
# 汇总处理文件总大小
|
||||
total_size = 0
|
||||
file_count = 0
|
||||
|
||||
# 剧集汇总
|
||||
episodes = []
|
||||
for file in media_files:
|
||||
transferinfo = file.get("transferinfo")
|
||||
total_size += transferinfo.total_size
|
||||
file_count += 1
|
||||
|
||||
file_meta = file.get("file_meta")
|
||||
if file_meta and file_meta.begin_episode:
|
||||
episodes.append(file_meta.begin_episode)
|
||||
|
||||
transferinfo.total_size = total_size
|
||||
# 汇总处理文件数量
|
||||
transferinfo.file_count = file_count
|
||||
|
||||
# 剧集季集信息 S01 E01-E04 || S01 E01、E02、E04
|
||||
season_episode = None
|
||||
# 处理文件多,说明是剧集,显示季入库消息
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 季集文本
|
||||
season_episode = f"{file_meta.season} {StringUtils.format_ep(episodes)}"
|
||||
# 发送消息
|
||||
self.transferchain.send_transfer_message(meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
transferinfo=transferinfo,
|
||||
season_episode=season_episode)
|
||||
# 发送完消息,移出key
|
||||
del self._msg_medias[medis_title_year_season]
|
||||
continue
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
退出插件
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -11,6 +11,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
@@ -121,6 +122,11 @@ class Scheduler(metaclass=Singleton):
|
||||
"name": "站点数据刷新",
|
||||
"func": SiteChain().refresh_userdatas,
|
||||
"running": False,
|
||||
},
|
||||
"recommend_refresh": {
|
||||
"name": "推荐缓存",
|
||||
"func": RecommendChain().refresh_recommend,
|
||||
"running": False,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +316,19 @@ class Scheduler(metaclass=Singleton):
|
||||
}
|
||||
)
|
||||
|
||||
# 推荐缓存
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="recommend_refresh",
|
||||
name="推荐缓存",
|
||||
hours=24,
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
kwargs={
|
||||
'job_id': 'recommend_refresh'
|
||||
}
|
||||
)
|
||||
|
||||
self.init_plugin_jobs()
|
||||
|
||||
# 打印服务
|
||||
@@ -568,6 +587,6 @@ class Scheduler(metaclass=Singleton):
|
||||
|
||||
else:
|
||||
self._auth_count += 1
|
||||
logger.error(f"用户认证失败:{msg},共失败 {self._auth_count} 次")
|
||||
logger.error(f"用户认证失败,{msg},共失败 {self._auth_count} 次")
|
||||
if self._auth_count >= __max_try__:
|
||||
logger.error("用户认证失败次数过多,将不再尝试认证!")
|
||||
|
||||
@@ -17,3 +17,5 @@ from .rule import *
|
||||
from .system import *
|
||||
from .file import *
|
||||
from .exception import *
|
||||
from .system import *
|
||||
from .event import *
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional, Dict, List, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MetaInfo(BaseModel):
|
||||
@@ -39,6 +39,8 @@ class MetaInfo(BaseModel):
|
||||
end_episode: Optional[int] = None
|
||||
# SxxExx
|
||||
season_episode: Optional[str] = None
|
||||
# 集列表
|
||||
episode_list: Optional[List[int]] = Field(default_factory=list)
|
||||
# Partx Cd Dvd Disk Disc
|
||||
part: Optional[str] = None
|
||||
# 识别的资源类型
|
||||
@@ -104,56 +106,56 @@ class MediaInfo(BaseModel):
|
||||
# 二级分类
|
||||
category: Optional[str] = ""
|
||||
# 季季集清单
|
||||
seasons: Optional[Dict[int, list]] = {}
|
||||
seasons: Optional[Dict[int, list]] = Field(default_factory=dict)
|
||||
# 季详情
|
||||
season_info: Optional[List[dict]] = []
|
||||
season_info: Optional[List[dict]] = Field(default_factory=list)
|
||||
# 别名和译名
|
||||
names: Optional[list] = []
|
||||
names: Optional[list] = Field(default_factory=list)
|
||||
# 演员
|
||||
actors: Optional[list] = []
|
||||
actors: Optional[list] = Field(default_factory=list)
|
||||
# 导演
|
||||
directors: Optional[list] = []
|
||||
directors: Optional[list] = Field(default_factory=list)
|
||||
# 详情链接
|
||||
detail_link: Optional[str] = None
|
||||
# 其它TMDB属性
|
||||
# 是否成人内容
|
||||
adult: Optional[bool] = False
|
||||
# 创建人
|
||||
created_by: Optional[list] = []
|
||||
created_by: Optional[list] = Field(default_factory=list)
|
||||
# 集时长
|
||||
episode_run_time: Optional[list] = []
|
||||
episode_run_time: Optional[list] = Field(default_factory=list)
|
||||
# 风格
|
||||
genres: Optional[List[dict]] = []
|
||||
genres: Optional[List[dict]] = Field(default_factory=list)
|
||||
# 首播日期
|
||||
first_air_date: Optional[str] = None
|
||||
# 首页
|
||||
homepage: Optional[str] = None
|
||||
# 语种
|
||||
languages: Optional[list] = []
|
||||
languages: Optional[list] = Field(default_factory=list)
|
||||
# 最后上映日期
|
||||
last_air_date: Optional[str] = None
|
||||
# 流媒体平台
|
||||
networks: Optional[list] = []
|
||||
networks: Optional[list] = Field(default_factory=list)
|
||||
# 集数
|
||||
number_of_episodes: Optional[int] = 0
|
||||
# 季数
|
||||
number_of_seasons: Optional[int] = 0
|
||||
# 原产国
|
||||
origin_country: Optional[list] = []
|
||||
origin_country: Optional[list] = Field(default_factory=list)
|
||||
# 原名
|
||||
original_name: Optional[str] = None
|
||||
# 出品公司
|
||||
production_companies: Optional[list] = []
|
||||
production_companies: Optional[list] = Field(default_factory=list)
|
||||
# 出品国
|
||||
production_countries: Optional[list] = []
|
||||
production_countries: Optional[list] = Field(default_factory=list)
|
||||
# 语种
|
||||
spoken_languages: Optional[list] = []
|
||||
spoken_languages: Optional[list] = Field(default_factory=list)
|
||||
# 状态
|
||||
status: Optional[str] = None
|
||||
# 标签
|
||||
tagline: Optional[str] = None
|
||||
# 风格ID
|
||||
genre_ids: Optional[list] = []
|
||||
genre_ids: Optional[list] = Field(default_factory=list)
|
||||
# 评价数量
|
||||
vote_count: Optional[int] = 0
|
||||
# 流行度
|
||||
@@ -161,7 +163,7 @@ class MediaInfo(BaseModel):
|
||||
# 时长
|
||||
runtime: Optional[int] = None
|
||||
# 下一集
|
||||
next_episode_to_air: Optional[dict] = {}
|
||||
next_episode_to_air: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TorrentInfo(BaseModel):
|
||||
@@ -213,7 +215,7 @@ class TorrentInfo(BaseModel):
|
||||
# HR
|
||||
hit_and_run: Optional[bool] = False
|
||||
# 种子标签
|
||||
labels: Optional[list] = []
|
||||
labels: Optional[list] = Field(default_factory=list)
|
||||
# 种子优先级
|
||||
pri_order: Optional[int] = 0
|
||||
# 促销
|
||||
@@ -245,13 +247,13 @@ class MediaPerson(BaseModel):
|
||||
type: Optional[Union[str, int]] = 1
|
||||
name: Optional[str] = None
|
||||
character: Optional[str] = None
|
||||
images: Optional[dict] = {}
|
||||
images: Optional[dict] = Field(default_factory=dict)
|
||||
# themoviedb
|
||||
profile_path: Optional[str] = None
|
||||
gender: Optional[Union[str, int]] = None
|
||||
original_name: Optional[str] = None
|
||||
credit_id: Optional[str] = None
|
||||
also_known_as: Optional[list] = []
|
||||
also_known_as: Optional[list] = Field(default_factory=list)
|
||||
birthday: Optional[str] = None
|
||||
deathday: Optional[str] = None
|
||||
imdb_id: Optional[str] = None
|
||||
@@ -260,11 +262,11 @@ class MediaPerson(BaseModel):
|
||||
popularity: Optional[float] = None
|
||||
biography: Optional[str] = None
|
||||
# douban
|
||||
roles: Optional[list] = []
|
||||
roles: Optional[list] = Field(default_factory=list)
|
||||
title: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
avatar: Optional[Union[str, dict]] = None
|
||||
latin_name: Optional[str] = None
|
||||
# bangumi
|
||||
career: Optional[list] = []
|
||||
career: Optional[list] = Field(default_factory=list)
|
||||
relation: Optional[str] = None
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
|
||||
|
||||
@@ -46,12 +45,12 @@ class AuthCredentials(ChainEventData):
|
||||
|
||||
# 输出参数
|
||||
# grant_type 为 authorization_code 时,输出参数包括 username、token、channel、service
|
||||
token: Optional[str] = Field(None, description="认证令牌")
|
||||
channel: Optional[str] = Field(None, description="认证渠道")
|
||||
service: Optional[str] = Field(None, description="服务名称")
|
||||
token: Optional[str] = Field(default=None, description="认证令牌")
|
||||
channel: Optional[str] = Field(default=None, description="认证渠道")
|
||||
service: Optional[str] = Field(default=None, description="服务名称")
|
||||
|
||||
@root_validator(pre=True)
|
||||
def check_fields_based_on_grant_type(cls, values):
|
||||
def check_fields_based_on_grant_type(cls, values): # noqa
|
||||
grant_type = values.get("grant_type")
|
||||
if not grant_type:
|
||||
values["grant_type"] = "password"
|
||||
@@ -89,11 +88,11 @@ class AuthInterceptCredentials(ChainEventData):
|
||||
channel: str = Field(..., description="认证渠道")
|
||||
service: str = Field(..., description="服务名称")
|
||||
status: str = Field(..., description="认证状态, 包含 'triggered' 表示认证触发,'completed' 表示认证成功")
|
||||
token: Optional[str] = Field(None, description="认证令牌")
|
||||
token: Optional[str] = Field(default=None, description="认证令牌")
|
||||
|
||||
# 输出参数
|
||||
source: str = Field("未知拦截源", description="拦截源")
|
||||
cancel: bool = Field(False, description="是否取消认证")
|
||||
source: str = Field(default="未知拦截源", description="拦截源")
|
||||
cancel: bool = Field(default=False, description="是否取消认证")
|
||||
|
||||
|
||||
class CommandRegisterEventData(ChainEventData):
|
||||
@@ -116,8 +115,8 @@ class CommandRegisterEventData(ChainEventData):
|
||||
service: Optional[str] = Field(..., description="服务名称")
|
||||
|
||||
# 输出参数
|
||||
cancel: bool = Field(False, description="是否取消注册")
|
||||
source: str = Field("未知拦截源", description="拦截源")
|
||||
cancel: bool = Field(default=False, description="是否取消注册")
|
||||
source: str = Field(default="未知拦截源", description="拦截源")
|
||||
|
||||
|
||||
class TransferRenameEventData(ChainEventData):
|
||||
@@ -143,9 +142,9 @@ class TransferRenameEventData(ChainEventData):
|
||||
render_str: str = Field(..., description="渲染生成的字符串")
|
||||
|
||||
# 输出参数
|
||||
updated: bool = Field(False, description="是否已更新")
|
||||
updated_str: Optional[str] = Field(None, description="更新后的字符串")
|
||||
source: Optional[str] = Field("未知拦截源", description="拦截源")
|
||||
updated: bool = Field(default=False, description="是否已更新")
|
||||
updated_str: Optional[str] = Field(default=None, description="更新后的字符串")
|
||||
source: Optional[str] = Field(default="未知拦截源", description="拦截源")
|
||||
|
||||
|
||||
class ResourceSelectionEventData(BaseModel):
|
||||
@@ -165,11 +164,12 @@ class ResourceSelectionEventData(BaseModel):
|
||||
# 输入参数
|
||||
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="拦截源")
|
||||
updated: bool = Field(default=False, description="是否已更新")
|
||||
updated_contexts: Optional[List[Any]] = Field(default=None, description="已更新的资源上下文列表")
|
||||
source: Optional[str] = Field(default="未知拦截源", description="拦截源")
|
||||
|
||||
|
||||
class ResourceDownloadEventData(ChainEventData):
|
||||
@@ -199,6 +199,6 @@ class ResourceDownloadEventData(ChainEventData):
|
||||
options: Optional[dict] = Field(None, description="其他参数")
|
||||
|
||||
# 输出参数
|
||||
cancel: bool = Field(False, description="是否取消下载")
|
||||
source: str = Field("未知拦截源", description="拦截源")
|
||||
reason: str = Field("", description="拦截原因")
|
||||
cancel: bool = Field(default=False, description="是否取消下载")
|
||||
source: str = Field(default="未知拦截源", description="拦截源")
|
||||
reason: str = Field(default="", description="拦截原因")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FileItem(BaseModel):
|
||||
@@ -21,7 +21,7 @@ class FileItem(BaseModel):
|
||||
# 修改时间
|
||||
modify_time: Optional[float] = None
|
||||
# 子节点
|
||||
children: Optional[list] = []
|
||||
children: Optional[list] = Field(default_factory=list)
|
||||
# ID
|
||||
fileid: Optional[str] = None
|
||||
# 父ID
|
||||
@@ -45,4 +45,4 @@ class StorageUsage(BaseModel):
|
||||
|
||||
class StorageTransType(BaseModel):
|
||||
# 传输类型
|
||||
transtype: Optional[dict] = {}
|
||||
transtype: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
@@ -46,6 +46,8 @@ class DownloadHistory(BaseModel):
|
||||
date: Optional[str] = None
|
||||
# 备注
|
||||
note: Optional[Any] = None
|
||||
# 自定义媒体类别
|
||||
media_category: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Union, List, Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
@@ -13,7 +13,7 @@ class ExistMediaInfo(BaseModel):
|
||||
# 类型 电影、电视剧
|
||||
type: Optional[MediaType]
|
||||
# 季
|
||||
seasons: Optional[Dict[int, list]] = {}
|
||||
seasons: Optional[Dict[int, list]] = Field(default_factory=dict)
|
||||
# 媒体服务器类型:plex、jellyfin、emby
|
||||
server_type: Optional[str] = None
|
||||
# 媒体服务器名称
|
||||
@@ -29,7 +29,7 @@ class NotExistMediaInfo(BaseModel):
|
||||
# 季
|
||||
season: Optional[int] = None
|
||||
# 剧集列表
|
||||
episodes: Optional[list] = []
|
||||
episodes: Optional[list] = Field(default_factory=list)
|
||||
# 总集数
|
||||
total_episode: Optional[int] = 0
|
||||
# 开始集
|
||||
@@ -132,7 +132,7 @@ class MediaServerSeasonInfo(BaseModel):
|
||||
媒体服务器媒体剧集信息
|
||||
"""
|
||||
season: Optional[int] = None
|
||||
episodes: Optional[List[int]] = []
|
||||
episodes: Optional[List[int]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebhookEventInfo(BaseModel):
|
||||
@@ -173,4 +173,4 @@ class MediaServerPlayItem(BaseModel):
|
||||
image: Optional[str] = None
|
||||
link: Optional[str] = None
|
||||
percent: Optional[float] = None
|
||||
BackdropImageTags: Optional[list] = []
|
||||
BackdropImageTags: Optional[list] = Field(default_factory=list)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.types import NotificationType, MessageChannel
|
||||
|
||||
@@ -101,7 +101,7 @@ class Subscription(BaseModel):
|
||||
客户端消息订阅
|
||||
"""
|
||||
endpoint: Optional[str]
|
||||
keys: Optional[dict] = {}
|
||||
keys: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SubscriptionMessage(BaseModel):
|
||||
@@ -112,4 +112,4 @@ class SubscriptionMessage(BaseModel):
|
||||
body: Optional[str]
|
||||
icon: Optional[str]
|
||||
url: Optional[str]
|
||||
data: Optional[dict] = {}
|
||||
data: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Plugin(BaseModel):
|
||||
@@ -43,7 +43,7 @@ class Plugin(BaseModel):
|
||||
# 安装次数
|
||||
install_count: Optional[int] = 0
|
||||
# 更新记录
|
||||
history: Optional[dict] = {}
|
||||
history: Optional[dict] = Field(default_factory=dict)
|
||||
# 添加时间,值越小表示越靠后发布
|
||||
add_time: Optional[int] = 0
|
||||
# 插件公钥
|
||||
@@ -60,8 +60,8 @@ class PluginDashboard(Plugin):
|
||||
# 仪表板key
|
||||
key: Optional[str] = None
|
||||
# 全局配置
|
||||
attrs: Optional[dict] = {}
|
||||
attrs: Optional[dict] = Field(default_factory=dict)
|
||||
# col列数
|
||||
cols: Optional[dict] = {}
|
||||
cols: Optional[dict] = Field(default_factory=dict)
|
||||
# 页面元素
|
||||
elements: Optional[List[dict]] = []
|
||||
elements: Optional[List[dict]] = Field(default_factory=list)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Response(BaseModel):
|
||||
@@ -9,4 +9,4 @@ class Response(BaseModel):
|
||||
# 消息文本
|
||||
message: Optional[str] = None
|
||||
# 数据
|
||||
data: Optional[Union[dict, list]] = {}
|
||||
data: Optional[Union[dict, list]] = Field(default_factory=dict)
|
||||
|
||||
@@ -1,57 +1,59 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RadarrMovie(BaseModel):
|
||||
id: Optional[int]
|
||||
title: Optional[str]
|
||||
year: Optional[str]
|
||||
id: Optional[int] = None
|
||||
title: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
isAvailable: bool = False
|
||||
monitored: bool = False
|
||||
tmdbId: Optional[int]
|
||||
imdbId: Optional[str]
|
||||
titleSlug: Optional[str]
|
||||
folderName: Optional[str]
|
||||
path: Optional[str]
|
||||
profileId: Optional[int]
|
||||
qualityProfileId: Optional[int]
|
||||
added: Optional[str]
|
||||
tmdbId: Optional[int] = None
|
||||
imdbId: Optional[str] = None
|
||||
titleSlug: Optional[str] = None
|
||||
folderName: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
profileId: Optional[int] = None
|
||||
qualityProfileId: Optional[int] = None
|
||||
added: Optional[str] = None
|
||||
hasFile: bool = False
|
||||
|
||||
|
||||
class SonarrSeries(BaseModel):
|
||||
id: Optional[int]
|
||||
title: Optional[str]
|
||||
sortTitle: Optional[str]
|
||||
seasonCount: Optional[int]
|
||||
status: Optional[str]
|
||||
overview: Optional[str]
|
||||
network: Optional[str]
|
||||
airTime: Optional[str]
|
||||
images: list = []
|
||||
remotePoster: Optional[str]
|
||||
seasons: list = []
|
||||
year: Optional[str]
|
||||
path: Optional[str]
|
||||
profileId: Optional[int]
|
||||
languageProfileId: Optional[int]
|
||||
id: Optional[int] = None
|
||||
title: Optional[str] = None
|
||||
sortTitle: Optional[str] = None
|
||||
seasonCount: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
overview: Optional[str] = None
|
||||
network: Optional[str] = None
|
||||
airTime: Optional[str] = None
|
||||
images: list = Field(default_factory=list)
|
||||
remotePoster: Optional[str] = None
|
||||
seasons: list = Field(default_factory=list)
|
||||
year: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
profileId: Optional[int] = None
|
||||
languageProfileId: Optional[int] = None
|
||||
seasonFolder: bool = False
|
||||
monitored: bool = False
|
||||
useSceneNumbering: bool = False
|
||||
runtime: Optional[int]
|
||||
tmdbId: Optional[int]
|
||||
imdbId: Optional[str]
|
||||
tvdbId: Optional[int]
|
||||
tvRageId: Optional[int]
|
||||
tvMazeId: Optional[int]
|
||||
firstAired: Optional[str]
|
||||
seriesType: Optional[str]
|
||||
cleanTitle: Optional[str]
|
||||
titleSlug: Optional[str]
|
||||
certification: Optional[str]
|
||||
genres: list = []
|
||||
tags: list = []
|
||||
added: Optional[str]
|
||||
ratings: Optional[dict]
|
||||
qualityProfileId: Optional[int]
|
||||
statistics: dict = {}
|
||||
runtime: Optional[int] = None
|
||||
tmdbId: Optional[int] = None
|
||||
imdbId: Optional[str] = None
|
||||
tvdbId: Optional[int] = None
|
||||
tvRageId: Optional[int] = None
|
||||
tvMazeId: Optional[int] = None
|
||||
firstAired: Optional[str] = None
|
||||
seriesType: Optional[str] = None
|
||||
cleanTitle: Optional[str] = None
|
||||
titleSlug: Optional[str] = None
|
||||
certification: Optional[str] = None
|
||||
genres: list = Field(default_factory=list)
|
||||
tags: list = Field(default_factory=list)
|
||||
added: Optional[str] = None
|
||||
ratings: Optional[dict] = None
|
||||
qualityProfileId: Optional[int] = None
|
||||
statistics: dict = Field(default_factory=dict)
|
||||
isAvailable: Optional[bool] = False
|
||||
hasFile: Optional[bool] = False
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
from typing import Optional, Any, Union, Dict
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Site(BaseModel):
|
||||
# ID
|
||||
id: Optional[int]
|
||||
id: Optional[int] = None
|
||||
# 站点名称
|
||||
name: Optional[str]
|
||||
name: Optional[str] = None
|
||||
# 站点主域名Key
|
||||
domain: Optional[str]
|
||||
domain: Optional[str] = None
|
||||
# 站点地址
|
||||
url: Optional[str]
|
||||
url: Optional[str] = None
|
||||
# 站点优先级
|
||||
pri: Optional[int] = 0
|
||||
# RSS地址
|
||||
@@ -53,7 +53,7 @@ class Site(BaseModel):
|
||||
|
||||
class SiteStatistic(BaseModel):
|
||||
# 站点ID
|
||||
domain: Optional[str]
|
||||
domain: Optional[str] = None
|
||||
# 成功次数
|
||||
success: Optional[int] = 0
|
||||
# 失败次数
|
||||
@@ -63,7 +63,7 @@ class SiteStatistic(BaseModel):
|
||||
# 最后状态
|
||||
lst_state: Optional[int] = 0
|
||||
# 最后修改时间
|
||||
lst_mod_date: Optional[str]
|
||||
lst_mod_date: Optional[str] = None
|
||||
# 备注
|
||||
note: Optional[Any] = None
|
||||
|
||||
@@ -73,15 +73,15 @@ class SiteStatistic(BaseModel):
|
||||
|
||||
class SiteUserData(BaseModel):
|
||||
# 站点域名
|
||||
domain: Optional[str]
|
||||
domain: Optional[str] = None
|
||||
# 用户名
|
||||
username: Optional[str]
|
||||
username: Optional[str] = None
|
||||
# 用户ID
|
||||
userid: Optional[Union[int, str]]
|
||||
userid: Optional[Union[int, str]] = None
|
||||
# 用户等级
|
||||
user_level: Optional[str]
|
||||
user_level: Optional[str] = None
|
||||
# 加入时间
|
||||
join_at: Optional[str]
|
||||
join_at: Optional[str] = None
|
||||
# 积分
|
||||
bonus: Optional[float] = 0.0
|
||||
# 上传量
|
||||
@@ -99,11 +99,11 @@ class SiteUserData(BaseModel):
|
||||
# 下载体积
|
||||
leeching_size: Optional[int] = 0
|
||||
# 做种人数, 种子大小
|
||||
seeding_info: Optional[list] = []
|
||||
seeding_info: Optional[list] = Field(default_factory=list)
|
||||
# 未读消息
|
||||
message_unread: Optional[int] = 0
|
||||
# 未读消息内容
|
||||
message_unread_contents: Optional[list] = []
|
||||
message_unread_contents: Optional[list] = Field(default_factory=list)
|
||||
# 错误信息
|
||||
err_msg: Optional[str] = None
|
||||
# 更新日期
|
||||
@@ -114,4 +114,4 @@ class SiteUserData(BaseModel):
|
||||
|
||||
class SiteAuth(BaseModel):
|
||||
site: Optional[str] = None
|
||||
params: Optional[Dict[str, Union[int, str]]] = {}
|
||||
params: Optional[Dict[str, Union[int, str]]] = Field(default_factory=dict)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Subscribe(BaseModel):
|
||||
@@ -53,7 +53,7 @@ class Subscribe(BaseModel):
|
||||
# 订阅用户
|
||||
username: Optional[str] = None
|
||||
# 订阅站点
|
||||
sites: Optional[List[int]] = []
|
||||
sites: Optional[List[int]] = Field(default_factory=list)
|
||||
# 下载器
|
||||
downloader: Optional[str] = None
|
||||
# 是否洗版
|
||||
@@ -71,7 +71,7 @@ class Subscribe(BaseModel):
|
||||
# 自定义媒体类别
|
||||
media_category: Optional[str] = None
|
||||
# 过滤规则组
|
||||
filter_groups: Optional[List[str]] = []
|
||||
filter_groups: Optional[List[str]] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -127,6 +127,8 @@ class SubscribeShare(BaseModel):
|
||||
custom_words: Optional[str] = None
|
||||
# 自定义媒体类别
|
||||
media_category: Optional[str] = None
|
||||
# 复用人次
|
||||
count: Optional[int] = 0
|
||||
|
||||
|
||||
class SubscribeDownloadFileInfo(BaseModel):
|
||||
@@ -157,13 +159,13 @@ class SubscribeEpisodeInfo(BaseModel):
|
||||
# 背景图
|
||||
backdrop: Optional[str] = None
|
||||
# 下载文件信息
|
||||
download: Optional[List[SubscribeDownloadFileInfo]] = []
|
||||
download: Optional[List[SubscribeDownloadFileInfo]] = Field(default_factory=list)
|
||||
# 媒体库文件信息
|
||||
library: Optional[List[SubscribeLibraryFileInfo]] = []
|
||||
library: Optional[List[SubscribeLibraryFileInfo]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SubscrbieInfo(BaseModel):
|
||||
# 订阅信息
|
||||
subscribe: Optional[Subscribe] = None
|
||||
# 集信息 {集号: {download: 文件路径,library: 文件路径, backdrop: url, title: 标题, description: 描述}}
|
||||
episodes: Optional[Dict[int, SubscribeEpisodeInfo]] = {}
|
||||
episodes: Optional[Dict[int, SubscribeEpisodeInfo]] = Field(default_factory=dict)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -30,11 +30,11 @@ class MediaServerConf(BaseModel):
|
||||
# 类型 emby/jellyfin/plex
|
||||
type: Optional[str] = None
|
||||
# 配置
|
||||
config: Optional[dict] = {}
|
||||
config: Optional[dict] = Field(default_factory=dict)
|
||||
# 是否启用
|
||||
enabled: Optional[bool] = False
|
||||
# 同步媒体体库列表
|
||||
sync_libraries: Optional[list] = []
|
||||
sync_libraries: Optional[list] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DownloaderConf(BaseModel):
|
||||
@@ -48,7 +48,7 @@ class DownloaderConf(BaseModel):
|
||||
# 是否默认
|
||||
default: Optional[bool] = False
|
||||
# 配置
|
||||
config: Optional[dict] = {}
|
||||
config: Optional[dict] = Field(default_factory=dict)
|
||||
# 是否启用
|
||||
enabled: Optional[bool] = False
|
||||
|
||||
@@ -62,9 +62,9 @@ class NotificationConf(BaseModel):
|
||||
# 类型 telegram/wechat/vocechat/synologychat/slack/webpush
|
||||
type: Optional[str] = None
|
||||
# 配置
|
||||
config: Optional[dict] = {}
|
||||
config: Optional[dict] = Field(default_factory=dict)
|
||||
# 场景开关
|
||||
switchs: Optional[list] = []
|
||||
switchs: Optional[list] = Field(default_factory=list)
|
||||
# 是否启用
|
||||
enabled: Optional[bool] = False
|
||||
|
||||
@@ -88,7 +88,7 @@ class StorageConf(BaseModel):
|
||||
# 名称
|
||||
name: Optional[str] = None
|
||||
# 配置
|
||||
config: Optional[dict] = {}
|
||||
config: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TransferDirectoryConf(BaseModel):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TmdbSeason(BaseModel):
|
||||
@@ -22,11 +22,12 @@ class TmdbEpisode(BaseModel):
|
||||
"""
|
||||
air_date: Optional[str] = None
|
||||
episode_number: Optional[int] = None
|
||||
episode_type: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
overview: Optional[str] = None
|
||||
runtime: Optional[int] = None
|
||||
season_number: Optional[int] = None
|
||||
still_path: Optional[str] = None
|
||||
vote_average: Optional[float] = None
|
||||
crew: Optional[list] = []
|
||||
guest_stars: Optional[list] = []
|
||||
crew: Optional[list] = Field(default_factory=list)
|
||||
guest_stars: Optional[list] = Field(default_factory=list)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Any, Callable
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.tmdb import TmdbEpisode
|
||||
from app.schemas.history import DownloadHistory
|
||||
from app.schemas.context import MetaInfo, MediaInfo
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
|
||||
|
||||
class TransferTorrent(BaseModel):
|
||||
@@ -34,15 +38,69 @@ class DownloadingTorrent(BaseModel):
|
||||
state: Optional[str] = 'downloading'
|
||||
upspeed: Optional[str] = None
|
||||
dlspeed: Optional[str] = None
|
||||
media: Optional[dict] = {}
|
||||
media: Optional[dict] = Field(default_factory=dict)
|
||||
userid: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
left_time: Optional[str] = None
|
||||
|
||||
|
||||
class TransferTask(BaseModel):
|
||||
"""
|
||||
文件整理任务
|
||||
"""
|
||||
fileitem: FileItem = None
|
||||
meta: Any = None
|
||||
mediainfo: Optional[Any] = None
|
||||
target_directory: Optional[TransferDirectoryConf] = None
|
||||
target_storage: Optional[str] = None
|
||||
target_path: Optional[Path] = None
|
||||
transfer_type: Optional[str] = None
|
||||
scrape: Optional[bool] = False
|
||||
library_type_folder: Optional[bool] = False
|
||||
library_category_folder: Optional[bool] = False
|
||||
episodes_info: Optional[List[TmdbEpisode]] = None
|
||||
username: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
download_history: Optional[DownloadHistory] = None
|
||||
manual: Optional[bool] = False
|
||||
background: Optional[bool] = True
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
返回字典
|
||||
"""
|
||||
dicts = vars(self).copy()
|
||||
dicts["fileitem"] = self.fileitem.dict() if self.fileitem else None
|
||||
dicts["meta"] = self.meta.dict() if self.meta else None
|
||||
dicts["mediainfo"] = self.mediainfo.dict() if self.mediainfo else None
|
||||
dicts["target_directory"] = self.target_directory.dict() if self.target_directory else None
|
||||
return dicts
|
||||
|
||||
|
||||
class TransferJobTask(BaseModel):
|
||||
"""
|
||||
文件整理作业任务
|
||||
"""
|
||||
fileitem: Optional[FileItem] = None
|
||||
meta: Optional[MetaInfo] = None
|
||||
state: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
|
||||
|
||||
class TransferJob(BaseModel):
|
||||
"""
|
||||
文件整理作业
|
||||
"""
|
||||
media: Optional[MediaInfo] = None
|
||||
season: Optional[int] = None
|
||||
tasks: Optional[List[TransferJobTask]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TransferInfo(BaseModel):
|
||||
"""
|
||||
文件转移结果信息
|
||||
文件整理结果
|
||||
"""
|
||||
# 是否成功标志
|
||||
success: bool = True
|
||||
@@ -57,13 +115,13 @@ class TransferInfo(BaseModel):
|
||||
# 处理文件数
|
||||
file_count: Optional[int] = 0
|
||||
# 处理文件清单
|
||||
file_list: Optional[list] = []
|
||||
file_list: Optional[list] = Field(default_factory=list)
|
||||
# 目标文件清单
|
||||
file_list_new: Optional[list] = []
|
||||
file_list_new: Optional[list] = Field(default_factory=list)
|
||||
# 总文件大小
|
||||
total_size: Optional[float] = 0
|
||||
# 失败清单
|
||||
fail_list: Optional[list] = []
|
||||
fail_list: Optional[list] = Field(default_factory=list)
|
||||
# 错误信息
|
||||
message: Optional[str] = None
|
||||
# 是否需要刮削
|
||||
@@ -81,6 +139,18 @@ class TransferInfo(BaseModel):
|
||||
return dicts
|
||||
|
||||
|
||||
class TransferQueue(BaseModel):
|
||||
"""
|
||||
异步整理队列信息
|
||||
"""
|
||||
# 任务信息
|
||||
task: Optional[TransferTask] = None
|
||||
# 回调函数
|
||||
callback: Optional[Callable] = None
|
||||
# 整理结果
|
||||
result: Optional[TransferInfo] = None
|
||||
|
||||
|
||||
class EpisodeFormat(BaseModel):
|
||||
"""
|
||||
剧集自定义识别格式
|
||||
@@ -89,3 +159,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
|
||||
|
||||
@@ -48,6 +48,8 @@ class EventType(Enum):
|
||||
NoticeMessage = "notice.message"
|
||||
# 订阅已添加
|
||||
SubscribeAdded = "subscribe.added"
|
||||
# 订阅已删除
|
||||
SubscribeDeleted = "subscribe.deleted"
|
||||
# 订阅已完成
|
||||
SubscribeComplete = "subscribe.complete"
|
||||
# 系统错误
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# Shared properties
|
||||
@@ -18,9 +18,9 @@ class UserBase(BaseModel):
|
||||
# 是否开启二次验证
|
||||
is_otp: Optional[bool] = False
|
||||
# 权限
|
||||
permissions: Optional[dict] = {}
|
||||
permissions: Optional[dict] = Field(default_factory=dict)
|
||||
# 个性化设置
|
||||
settings: Optional[dict] = {}
|
||||
settings: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -31,7 +31,7 @@ class UserCreate(UserBase):
|
||||
name: str
|
||||
email: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
settings: Optional[dict] = {}
|
||||
settings: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# Properties to receive via API on update
|
||||
@@ -40,7 +40,7 @@ class UserUpdate(UserBase):
|
||||
name: str
|
||||
email: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
settings: Optional[dict] = {}
|
||||
settings: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserInDBBase(UserBase):
|
||||
|
||||
@@ -4,12 +4,14 @@ from fastapi import FastAPI
|
||||
|
||||
from app.core.config import global_vars, settings
|
||||
from app.core.module import ModuleManager
|
||||
from app.log import logger
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
# SitesHelper涉及资源包拉取,提前引入并容错提示
|
||||
try:
|
||||
from app.helper.sites import SitesHelper
|
||||
except ImportError as e:
|
||||
SitesHelper = None
|
||||
error_message = f"错误: {str(e)}\n站点认证及索引相关资源导入失败,请尝试重建容器或手动拉取资源"
|
||||
print(error_message, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -26,7 +28,7 @@ from app.schemas import Notification, NotificationType
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.db import close_database
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.chain.command import CommandChain
|
||||
from app.command import Command, CommandChain
|
||||
|
||||
|
||||
def start_frontend():
|
||||
@@ -78,13 +80,15 @@ def user_auth():
|
||||
"""
|
||||
用户认证检查
|
||||
"""
|
||||
if SitesHelper().auth_level >= 2:
|
||||
sites_helper = SitesHelper()
|
||||
if sites_helper.auth_level >= 2:
|
||||
return
|
||||
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
|
||||
if auth_conf:
|
||||
SitesHelper().check_user(**auth_conf)
|
||||
status, msg = sites_helper.check_user(**auth_conf) if auth_conf else sites_helper.check_user()
|
||||
if status:
|
||||
logger.info(f"{msg} 用户认证成功")
|
||||
else:
|
||||
SitesHelper().check_user()
|
||||
logger.info(f"用户认证失败,{msg}")
|
||||
|
||||
|
||||
def check_auth():
|
||||
@@ -156,7 +160,7 @@ def start_modules(_: FastAPI):
|
||||
# 启动定时服务
|
||||
Scheduler()
|
||||
# 加载命令
|
||||
CommandChain()
|
||||
Command()
|
||||
# 启动前端服务
|
||||
start_frontend()
|
||||
# 检查认证状态
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
|
||||
from app.chain.command import CommandChain
|
||||
from app.command import Command
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
@@ -14,7 +14,7 @@ async def init_plugins_async():
|
||||
loop = asyncio.get_event_loop()
|
||||
plugin_manager = PluginManager()
|
||||
scheduler = Scheduler()
|
||||
command = CommandChain()
|
||||
command = Command()
|
||||
|
||||
sync_result = await execute_task(loop, plugin_manager.sync, "插件同步到本地")
|
||||
resolved_dependencies = await execute_task(loop, plugin_manager.install_plugin_missing_dependencies,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import time
|
||||
from typing import Any
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.schemas import ImmediateException
|
||||
|
||||
@@ -36,3 +37,27 @@ def retry(ExceptionToCheck: Any,
|
||||
return f_retry
|
||||
|
||||
return deco_retry
|
||||
|
||||
|
||||
def log_execution_time(logger: Any = None):
|
||||
"""
|
||||
记录函数执行时间的装饰器
|
||||
:param logger: 日志记录器对象,用于记录异常信息
|
||||
"""
|
||||
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
start_time = time.time()
|
||||
result = func(*args, **kwargs)
|
||||
end_time = time.time()
|
||||
msg = f"{func.__name__} execution time: {end_time - start_time:.2f} seconds"
|
||||
if logger:
|
||||
logger.debug(msg)
|
||||
else:
|
||||
print(msg)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -17,6 +17,11 @@ _special_domains = [
|
||||
'pt.ecust.pp.ua',
|
||||
]
|
||||
|
||||
# 内置版本号转换字典
|
||||
_version_map = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4}
|
||||
# 不符合的版本号
|
||||
_other_version = -5
|
||||
|
||||
|
||||
class StringUtils:
|
||||
|
||||
@@ -222,7 +227,7 @@ class StringUtils:
|
||||
size = float(size)
|
||||
d = [(1024 - 1, 'K'), (1024 ** 2 - 1, 'M'), (1024 ** 3 - 1, 'G'), (1024 ** 4 - 1, 'T')]
|
||||
s = [x[0] for x in d]
|
||||
index = bisect.bisect_left(s, size) - 1
|
||||
index = bisect.bisect_left(s, size) - 1 # noqa
|
||||
if index == -1:
|
||||
return str(size) + "B"
|
||||
else:
|
||||
@@ -740,27 +745,122 @@ class StringUtils:
|
||||
return ''.join(common_prefix)
|
||||
|
||||
@staticmethod
|
||||
def compare_version(v1: str, v2: str) -> int:
|
||||
def compare_version(v1: str, compare_type: str, v2: str, verbose: bool = False) \
|
||||
-> Tuple[Optional[bool], str | Exception] | Optional[bool]:
|
||||
"""
|
||||
比较两个版本号的大小,v1 > v2时返回1,v1 < v2时返回-1,v1 = v2时返回0
|
||||
比较两个版本号的大小
|
||||
|
||||
:param v1: 比对的来源版本号
|
||||
:param v2: 比对的目标版本号
|
||||
:param verbose: 是否输出比对结果的时候输出详细消息,默认 False 不输出
|
||||
:param compare_type: 识别模式。支持直接使用符号进行比对
|
||||
'ge' or '>=' :来源 >= 目标
|
||||
'le' or '<=' :来源 <= 目标
|
||||
'eq' or '==' :来源 == 目标
|
||||
'gt' or '>' :来源 > 目标
|
||||
'lt' or '<' :来源 < 目标
|
||||
:return
|
||||
"""
|
||||
if not v1 or not v2:
|
||||
return 0
|
||||
v1 = v1.replace('v', '')
|
||||
v2 = v2.replace('v', '')
|
||||
v1 = [int(x) for x in v1.split('.')]
|
||||
v2 = [int(x) for x in v2.split('.')]
|
||||
for i in range(min(len(v1), len(v2))):
|
||||
if v1[i] > v2[i]:
|
||||
return 1
|
||||
elif v1[i] < v2[i]:
|
||||
return -1
|
||||
if len(v1) > len(v2):
|
||||
return 1
|
||||
elif len(v1) < len(v2):
|
||||
return -1
|
||||
else:
|
||||
return 0
|
||||
|
||||
def __preprocess_version(version: str) -> list:
|
||||
"""
|
||||
预处理版本号,去除首尾空字符串与换行符,去除开头大小写v,并拆分版本号
|
||||
"""
|
||||
return re.split(r'[.-]', version.strip().lstrip('vV'))
|
||||
|
||||
def __conversion_version(version_list) -> list:
|
||||
"""
|
||||
英文字符转换为数字
|
||||
:param version_list : 版本号列表,格式:['1', '2', '3', 'beta']
|
||||
"""
|
||||
result = []
|
||||
for item in version_list:
|
||||
# stable = -1,rc = -2,beta = -3,alpha = -4
|
||||
if item.isdigit():
|
||||
result.append(int(item))
|
||||
# 其余不符合的,都为-5
|
||||
else:
|
||||
value = _version_map.get(item, _other_version)
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
try:
|
||||
if not v1 or not v2:
|
||||
raise ValueError("要比较的版本号不全")
|
||||
if not compare_type:
|
||||
raise ValueError("缺少比对模式,无法比对")
|
||||
if compare_type not in {"ge", "gt", "le", "lt", "eq", "==", ">=", ">", "<=", "<"}:
|
||||
raise ValueError(f"设置的版本比对模式 {compare_type} 不是有效的模式!")
|
||||
|
||||
# 拆分获取版本号各个分段值做成列表
|
||||
v1_list = __conversion_version(__preprocess_version(version=v1))
|
||||
v2_list = __conversion_version(__preprocess_version(version=v2))
|
||||
|
||||
# 补全版本号位置,保持长度一致
|
||||
max_length = max(len(v1_list), len(v2_list))
|
||||
v1_list += [0] * (max_length - len(v1_list))
|
||||
v2_list += [0] * (max_length - len(v2_list))
|
||||
|
||||
ver_comparison, ver_comparison_err = None, None
|
||||
for v1_value, v2_value in zip(v1_list, v2_list):
|
||||
# 来源==目标
|
||||
if compare_type in {"eq", "=="}:
|
||||
if v1_value != v2_value:
|
||||
ver_comparison, ver_comparison_err = None, "不等于"
|
||||
break
|
||||
else:
|
||||
ver_comparison, ver_comparison_err = "等于", None
|
||||
|
||||
# 来源>=目标
|
||||
elif compare_type in {"ge", ">="}:
|
||||
if v1_value > v2_value:
|
||||
ver_comparison, ver_comparison_err = "大于", None
|
||||
break
|
||||
elif v1_value < v2_value:
|
||||
ver_comparison, ver_comparison_err = None, "小于"
|
||||
break
|
||||
else:
|
||||
ver_comparison, ver_comparison_err = "等于", None
|
||||
|
||||
# 来源>目标
|
||||
elif compare_type in {"gt", ">"}:
|
||||
if v1_value > v2_value:
|
||||
ver_comparison, ver_comparison_err = "大于", None
|
||||
break
|
||||
elif v1_value < v2_value:
|
||||
ver_comparison, ver_comparison_err = None, "小于"
|
||||
break
|
||||
else:
|
||||
ver_comparison, ver_comparison_err = None, "等于"
|
||||
|
||||
# 来源<=目标
|
||||
elif compare_type in {"le", "<="}:
|
||||
if v1_value > v2_value:
|
||||
ver_comparison, ver_comparison_err = None, "大于"
|
||||
break
|
||||
elif v1_value < v2_value:
|
||||
ver_comparison, ver_comparison_err = "小于", None
|
||||
break
|
||||
else:
|
||||
ver_comparison, ver_comparison_err = "等于", None
|
||||
|
||||
# 来源<目标
|
||||
elif compare_type in {"lt", "<"}:
|
||||
if v1_value > v2_value:
|
||||
ver_comparison, ver_comparison_err = None, "大于"
|
||||
break
|
||||
elif v1_value < v2_value:
|
||||
ver_comparison, ver_comparison_err = "小于", None
|
||||
break
|
||||
else:
|
||||
ver_comparison, ver_comparison_err = None, "等于"
|
||||
|
||||
msg = f"版本号 {v1} {ver_comparison if ver_comparison else ver_comparison_err} 目标版本号 {v2} !"
|
||||
|
||||
return (True if ver_comparison else False, msg) if verbose else True if ver_comparison else False
|
||||
|
||||
except Exception as e:
|
||||
return (None, e) if verbose else None
|
||||
|
||||
@staticmethod
|
||||
def diff_time_str(time_str: str):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from typing import Optional, Union, Tuple
|
||||
from urllib import parse
|
||||
from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse
|
||||
|
||||
@@ -27,7 +27,7 @@ class UrlUtils:
|
||||
@staticmethod
|
||||
def adapt_request_url(host: str, endpoint: str) -> Optional[str]:
|
||||
"""
|
||||
基于传入的host,适配请求的URL,确保每个请求的URL是完整的,用于在发送请求前自动处理和修正请求的URL。
|
||||
基于传入的host,适配请求的URL,确保每个请求的URL是完整的,用于在发送请求前自动处理和修正请求的URL
|
||||
:param host: 主机头
|
||||
:param endpoint: 端点
|
||||
:return: 完整的请求URL字符串
|
||||
@@ -42,7 +42,7 @@ class UrlUtils:
|
||||
@staticmethod
|
||||
def combine_url(host: str, path: Optional[str] = None, query: Optional[dict] = None) -> Optional[str]:
|
||||
"""
|
||||
使用给定的主机头、路径和查询参数组合生成完整的URL。
|
||||
使用给定的主机头、路径和查询参数组合生成完整的URL
|
||||
:param host: str, 主机头,例如 https://example.com
|
||||
:param path: Optional[str], 包含路径和可能已经包含的查询参数的端点,例如 /path/to/resource?current=1
|
||||
:param query: Optional[dict], 可选,额外的查询参数,例如 {"key": "value"}
|
||||
@@ -101,9 +101,42 @@ class UrlUtils:
|
||||
def quote(s: str) -> str:
|
||||
"""
|
||||
将字符串编码为 URL 安全的格式
|
||||
这将确保路径中的特殊字符(如空格、中文字符等)被正确编码,以便在 URL 中传输
|
||||
|
||||
:param s: 要编码的字符串
|
||||
:return: 编码后的字符串
|
||||
"""
|
||||
return parse.quote(s)
|
||||
|
||||
@staticmethod
|
||||
def parse_url_params(url: str) -> Optional[Tuple[str, str, int, str]]:
|
||||
"""
|
||||
解析给定的 URL,并提取协议、主机名、端口和路径信息
|
||||
|
||||
:param url: str
|
||||
需要解析的 URL 字符串
|
||||
可以是完整的 URL(例如:"http://example.com:8080/path")或不带协议的地址(例如:"example.com:1234")
|
||||
:return: Optional[Tuple[str, str, int, str]]
|
||||
- str: 协议(例如:"http", "https")
|
||||
- str: 主机名或 IP 地址(例如:"example.com", "192.168.1.1")
|
||||
- int: 端口号(例如:80, 443)
|
||||
- str: URL 的路径部分(例如:"/", "/path")
|
||||
如果输入地址无效或无法解析,则返回 None
|
||||
"""
|
||||
try:
|
||||
if not url:
|
||||
return None
|
||||
|
||||
url = UrlUtils.standardize_base_url(host=url)
|
||||
parsed = urlparse(url)
|
||||
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
protocol = parsed.scheme
|
||||
hostname = parsed.hostname
|
||||
port = parsed.port or (443 if protocol == "https" else 80)
|
||||
path = parsed.path or "/"
|
||||
|
||||
return protocol, hostname, port, path
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parse_url_params: {e}")
|
||||
return None
|
||||
|
||||
30
database/versions/55390f1f77c1_2_0_9.py
Normal file
30
database/versions/55390f1f77c1_2_0_9.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""2.0.9
|
||||
|
||||
Revision ID: 55390f1f77c1
|
||||
Revises: bf28a012734c
|
||||
Create Date: 2024-12-24 13:29:32.225532
|
||||
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import sqlite
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '55390f1f77c1'
|
||||
down_revision = 'bf28a012734c'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# 整理历史记录 增加下载器字段
|
||||
with contextlib.suppress(Exception):
|
||||
op.add_column('transferhistory', sa.Column('downloader', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -5,9 +5,6 @@ Revises: ecf3c693fdf3
|
||||
Create Date: 2024-11-14 12:49:13.838120
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import sqlite
|
||||
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
29
database/versions/bf28a012734c_2_0_8.py
Normal file
29
database/versions/bf28a012734c_2_0_8.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""2.0.8
|
||||
|
||||
Revision ID: bf28a012734c
|
||||
Revises: eaf9cbc49027
|
||||
Create Date: 2024-12-23 18:29:31.202143
|
||||
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'bf28a012734c'
|
||||
down_revision = 'eaf9cbc49027'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# 下载历史记录 增加下载器字段
|
||||
with contextlib.suppress(Exception):
|
||||
op.add_column('downloadhistory', sa.Column('downloader', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -58,6 +58,10 @@ pystray~=0.19.5
|
||||
pyotp~=2.9.0
|
||||
Pinyin2Hanzi~=0.1.1
|
||||
pywebpush~=2.0.0
|
||||
python-115~=0.0.9.8.8.2
|
||||
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
|
||||
aiofiles~=24.1.0
|
||||
jieba~=0.42.1
|
||||
rsa~=4.9
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.1.2'
|
||||
FRONTEND_VERSION = 'v2.1.2'
|
||||
APP_VERSION = 'v2.2.1'
|
||||
FRONTEND_VERSION = 'v2.2.1'
|
||||
|
||||
Reference in New Issue
Block a user