mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-12 02:39:45 +08:00
Compare commits
166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f208b65570 | ||
|
|
8a0a530036 | ||
|
|
76643f13ed | ||
|
|
6992284a77 | ||
|
|
9a142799cd | ||
|
|
027d1567c3 | ||
|
|
65af737dfd | ||
|
|
48aa0e3d0b | ||
|
|
b4e31893ff | ||
|
|
4f1b95352a | ||
|
|
ca664cb569 | ||
|
|
fe4ea73286 | ||
|
|
9e9cca6de4 | ||
|
|
2e7e74c803 | ||
|
|
916597047d | ||
|
|
83fc474dbe | ||
|
|
f67bf49e69 | ||
|
|
bf9043f526 | ||
|
|
a98de604a1 | ||
|
|
e160a745a7 | ||
|
|
7f2c6ef167 | ||
|
|
2086651dbe | ||
|
|
132fde2308 | ||
|
|
4e27a1e623 | ||
|
|
a453831deb | ||
|
|
1035ceb4ac | ||
|
|
b7cb917347 | ||
|
|
680ad164dc | ||
|
|
aed68253e9 | ||
|
|
b83c7a5656 | ||
|
|
491456b0a2 | ||
|
|
84465a6536 | ||
|
|
9acbcf4922 | ||
|
|
8dc4290695 | ||
|
|
5c95945691 | ||
|
|
11115d50fb | ||
|
|
7f83d56a7e | ||
|
|
28805e9e17 | ||
|
|
88a098abc1 | ||
|
|
a3cc9830de | ||
|
|
43623efa99 | ||
|
|
ff73b2cb5d | ||
|
|
6cab14366c | ||
|
|
576d215d8c | ||
|
|
a2c10c86bf | ||
|
|
21bede3f00 | ||
|
|
0a39322281 | ||
|
|
be323d3da1 | ||
|
|
fa8860bf62 | ||
|
|
a700958edb | ||
|
|
9349973d16 | ||
|
|
c0d3637d12 | ||
|
|
79473ca229 | ||
|
|
fccbe39547 | ||
|
|
85324acacc | ||
|
|
9dec4d704b | ||
|
|
72732277a1 | ||
|
|
8d737f9e37 | ||
|
|
96b3746caa | ||
|
|
c690ea3c39 | ||
|
|
3282fb88e0 | ||
|
|
b9c2b9a044 | ||
|
|
24b58dc002 | ||
|
|
42c56497c6 | ||
|
|
c7512d1580 | ||
|
|
7d25bf7b48 | ||
|
|
99daa3a95e | ||
|
|
0a923bced9 | ||
|
|
06e3b0def2 | ||
|
|
0feecc3eca | ||
|
|
0afbc58263 | ||
|
|
7c7561029a | ||
|
|
65683999e1 | ||
|
|
f72e26015f | ||
|
|
b4e5c50655 | ||
|
|
f395dc68c3 | ||
|
|
27cf5bb7e6 | ||
|
|
9b573535cd | ||
|
|
cb32305b86 | ||
|
|
f7164450d0 | ||
|
|
344862dbd4 | ||
|
|
f1d0e9d50a | ||
|
|
9ba9e8f41c | ||
|
|
78fc5b7017 | ||
|
|
fe07830b71 | ||
|
|
350f1faf2a | ||
|
|
103cfe0b47 | ||
|
|
0953c1be16 | ||
|
|
c299bf6f7c | ||
|
|
c0eb9d824c | ||
|
|
ebffdebdb2 | ||
|
|
acd9e38477 | ||
|
|
9f4cf530f8 | ||
|
|
84897aa592 | ||
|
|
23c5982f5a | ||
|
|
1849930b72 | ||
|
|
4f1d3a7572 | ||
|
|
824c3ac5d6 | ||
|
|
1cec6ed6d1 | ||
|
|
fff75c7fe2 | ||
|
|
81fecf1e07 | ||
|
|
ad8f687f8e | ||
|
|
a3172d7503 | ||
|
|
8d5e0b26d5 | ||
|
|
b1b980f550 | ||
|
|
8196589cff | ||
|
|
cb9f41cb65 | ||
|
|
cb4981adb3 | ||
|
|
6880b42a84 | ||
|
|
97054adc61 | ||
|
|
de94e5d595 | ||
|
|
a5a734d091 | ||
|
|
efb607d22f | ||
|
|
d0b2787a7c | ||
|
|
d5988ff443 | ||
|
|
96b4f1b575 | ||
|
|
bb6b8439c7 | ||
|
|
9cdce4509d | ||
|
|
3956ab1fe8 | ||
|
|
14686fdb03 | ||
|
|
32892ab747 | ||
|
|
79c637e003 | ||
|
|
d7c260715a | ||
|
|
2dfb089a39 | ||
|
|
e04179525b | ||
|
|
d044364c68 | ||
|
|
a0f912ffbe | ||
|
|
d7c8b08d7a | ||
|
|
f752082e1b | ||
|
|
201ec21adf | ||
|
|
57590323b2 | ||
|
|
4636c7ada7 | ||
|
|
4c86a4da5f | ||
|
|
8dc9acf071 | ||
|
|
abebae3664 | ||
|
|
4f7d8866a0 | ||
|
|
cceb22d729 | ||
|
|
89edbb93f5 | ||
|
|
4ffb406172 | ||
|
|
293e417865 | ||
|
|
510c20dc70 | ||
|
|
8e1810955b | ||
|
|
73f732fe1d | ||
|
|
d6f5160959 | ||
|
|
d64a7086dd | ||
|
|
825d9b768f | ||
|
|
f758a47f4f | ||
|
|
fc69d7e6c1 | ||
|
|
edc30266c8 | ||
|
|
665da9dad3 | ||
|
|
4048acf60e | ||
|
|
f116229ecc | ||
|
|
f6a2efb256 | ||
|
|
af3a50f7ea | ||
|
|
44a0e5b4a7 | ||
|
|
f40a1246ff | ||
|
|
dd890c410c | ||
|
|
8fd7f2c875 | ||
|
|
8c09b3482f | ||
|
|
0066247a2b | ||
|
|
c7926fc575 | ||
|
|
ac5b9fd4e5 | ||
|
|
42dc539df6 | ||
|
|
e60d785a11 | ||
|
|
33558d6197 | ||
|
|
46d2ffeb75 |
45
.github/ISSUE_TEMPLATE/rfc.yml
vendored
Normal file
45
.github/ISSUE_TEMPLATE/rfc.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: 功能提案
|
||||||
|
description: Request for Comments
|
||||||
|
title: "[RFC]"
|
||||||
|
labels: ["RFC"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
一份提案(RFC)定位为 **「在某功能/重构的具体开发前,用于开发者间 review 技术设计/方案的文档」**,
|
||||||
|
目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论;
|
||||||
|
以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突),
|
||||||
|
因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。
|
||||||
|
|
||||||
|
如果仅希望讨论是否添加或改进某功能本身,请使用 -> [Issue: 功能改进](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.yml&title=%5BFeature+Request%5D%3A+)
|
||||||
|
- type: textarea
|
||||||
|
id: background
|
||||||
|
attributes:
|
||||||
|
label: 背景 or 问题
|
||||||
|
description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: goal
|
||||||
|
attributes:
|
||||||
|
label: "目标 & 方案简述"
|
||||||
|
description: 简单描述提案此提案实现后,**预期的目标效果**,以及简单大致描述会采取的方案/步骤,可能会/不会产生什么影响。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: design
|
||||||
|
attributes:
|
||||||
|
label: "方案设计 & 实现步骤"
|
||||||
|
description: |
|
||||||
|
详细描述你设计的具体方案,可以考虑拆分列表或要点,一步步描述具体打算如何实现的步骤和相关细节。
|
||||||
|
这部份不需要一次性写完整,即使在创建完此提案 issue 后,依旧可以再次编辑修改。
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: alternative
|
||||||
|
attributes:
|
||||||
|
label: "替代方案 & 对比"
|
||||||
|
description: |
|
||||||
|
[可选] 为来实现目标效果,还考虑过什么其他方案,有什么对比?
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -10,10 +10,7 @@ ENV LANG="C.UTF-8" \
|
|||||||
UMASK=000 \
|
UMASK=000 \
|
||||||
PORT=3001 \
|
PORT=3001 \
|
||||||
NGINX_PORT=3000 \
|
NGINX_PORT=3000 \
|
||||||
PROXY_HOST="" \
|
MOVIEPILOT_AUTO_UPDATE=release
|
||||||
MOVIEPILOT_AUTO_UPDATE=false \
|
|
||||||
AUTH_SITE="iyuu" \
|
|
||||||
IYUU_SIGN=""
|
|
||||||
WORKDIR "/app"
|
WORKDIR "/app"
|
||||||
RUN apt-get update -y \
|
RUN apt-get update -y \
|
||||||
&& apt-get upgrade -y \
|
&& apt-get upgrade -y \
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ from app.core.context import MediaInfo, Context, TorrentInfo
|
|||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
from app.db.models.user import User
|
from app.db.models.user import User
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.db.user_oper import get_current_active_user
|
from app.db.user_oper import get_current_active_user
|
||||||
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -111,6 +113,17 @@ def stop(hashString: str,
|
|||||||
return schemas.Response(success=True if ret else False)
|
return schemas.Response(success=True if ret else False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clients", summary="查询可用下载器", response_model=List[dict])
|
||||||
|
def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询可用下载器
|
||||||
|
"""
|
||||||
|
downloaders: List[dict] = SystemConfigOper().get(SystemConfigKey.Downloaders)
|
||||||
|
if downloaders:
|
||||||
|
return [{"name": d.get("name"), "type": d.get("type")} for d in downloaders if d.get("enabled")]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
||||||
def delete(hashString: str,
|
def delete(hashString: str,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
|||||||
@@ -116,9 +116,6 @@ def scrape(fileitem: schemas.FileItem,
|
|||||||
if storage == "local":
|
if storage == "local":
|
||||||
if not scrape_path.exists():
|
if not scrape_path.exists():
|
||||||
return schemas.Response(success=False, message="刮削路径不存在")
|
return schemas.Response(success=False, message="刮削路径不存在")
|
||||||
else:
|
|
||||||
if not fileitem.fileid:
|
|
||||||
return schemas.Response(success=False, message="刮削文件ID无效")
|
|
||||||
# 手动刮削
|
# 手动刮削
|
||||||
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||||
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ from app.core.security import verify_token
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.mediaserver_oper import MediaServerOper
|
from app.db.mediaserver_oper import MediaServerOper
|
||||||
from app.db.models import MediaServerItem
|
from app.db.models import MediaServerItem
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.helper.mediaserver import MediaServerHelper
|
from app.helper.mediaserver import MediaServerHelper
|
||||||
from app.schemas import MediaType, NotExistMediaInfo
|
from app.schemas import MediaType, NotExistMediaInfo
|
||||||
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -143,3 +145,14 @@ def library(server: str, hidden: bool = False,
|
|||||||
获取媒体服务器媒体库列表
|
获取媒体服务器媒体库列表
|
||||||
"""
|
"""
|
||||||
return MediaServerChain().librarys(server=server, username=userinfo.username, hidden=hidden) or []
|
return MediaServerChain().librarys(server=server, username=userinfo.username, hidden=hidden) or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clients", summary="查询可用媒体服务器", response_model=List[dict])
|
||||||
|
def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询可用媒体服务器
|
||||||
|
"""
|
||||||
|
mediaservers: List[dict] = SystemConfigOper().get(SystemConfigKey.MediaServers)
|
||||||
|
if mediaservers:
|
||||||
|
return [{"name": d.get("name"), "type": d.get("type")} for d in mediaservers if d.get("enabled")]
|
||||||
|
return []
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app import schemas
|
|||||||
from app.chain.site import SiteChain
|
from app.chain.site import SiteChain
|
||||||
from app.chain.torrents import TorrentsChain
|
from app.chain.torrents import TorrentsChain
|
||||||
from app.core.event import EventManager
|
from app.core.event import EventManager
|
||||||
|
from app.core.plugin import PluginManager
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
@@ -331,6 +332,31 @@ def read_rss_sites(db: Session = Depends(get_db),
|
|||||||
return rss_sites
|
return rss_sites
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth", summary="查询认证站点", response_model=dict)
|
||||||
|
def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
||||||
|
"""
|
||||||
|
获取可认证站点列表
|
||||||
|
"""
|
||||||
|
return SitesHelper().get_authsites()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth", summary="用户站点认证", response_model=schemas.Response)
|
||||||
|
def auth_site(
|
||||||
|
auth_info: schemas.SiteAuth,
|
||||||
|
_: User = Depends(get_current_active_superuser)
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
用户站点认证
|
||||||
|
"""
|
||||||
|
if not auth_info or not auth_info.site or not auth_info.params:
|
||||||
|
return schemas.Response(success=False, message="请输入认证站点和认证参数")
|
||||||
|
status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)
|
||||||
|
SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.dict())
|
||||||
|
PluginManager().init_config()
|
||||||
|
Scheduler().init_plugin_jobs()
|
||||||
|
return schemas.Response(success=status, message=msg)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site)
|
@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site)
|
||||||
def read_site(
|
def read_site(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
|
|||||||
@@ -151,8 +151,6 @@ def rename(fileitem: schemas.FileItem,
|
|||||||
"""
|
"""
|
||||||
if not new_name:
|
if not new_name:
|
||||||
return schemas.Response(success=False, message="新名称为空")
|
return schemas.Response(success=False, message="新名称为空")
|
||||||
if fileitem.storage != 'local' and not fileitem.fileid:
|
|
||||||
return schemas.Response(success=False, message="资源ID获取失败")
|
|
||||||
result = StorageChain().rename_file(fileitem, new_name)
|
result = StorageChain().rename_file(fileitem, new_name)
|
||||||
if result:
|
if result:
|
||||||
if recursive:
|
if recursive:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app import schemas
|
|||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
from app.chain.system import SystemChain
|
from app.chain.system import SystemChain
|
||||||
from app.core.config import global_vars, settings
|
from app.core.config import global_vars, settings
|
||||||
|
from app.core.metainfo import MetaInfo
|
||||||
from app.core.module import ModuleManager
|
from app.core.module import ModuleManager
|
||||||
from app.core.security import verify_apitoken, verify_resource_token, verify_token
|
from app.core.security import verify_apitoken, verify_resource_token, verify_token
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
@@ -385,9 +386,12 @@ def ruletest(title: str,
|
|||||||
if not rulegroup:
|
if not rulegroup:
|
||||||
return schemas.Response(success=False, message=f"过滤规则组 {rulegroup_name} 不存在!")
|
return schemas.Response(success=False, message=f"过滤规则组 {rulegroup_name} 不存在!")
|
||||||
|
|
||||||
|
# 根据标题查询媒体信息
|
||||||
|
media_info =SearchChain().recognize_media(MetaInfo(title=title, subtitle=subtitle))
|
||||||
|
|
||||||
# 过滤
|
# 过滤
|
||||||
result = SearchChain().filter_torrents(rule_groups=[rulegroup.name],
|
result = SearchChain().filter_torrents(rule_groups=[rulegroup.name],
|
||||||
torrent_list=[torrent])
|
torrent_list=[torrent], mediainfo=media_info)
|
||||||
if not result:
|
if not result:
|
||||||
return schemas.Response(success=False, message="不符合过滤规则!")
|
return schemas.Response(success=False, message="不符合过滤规则!")
|
||||||
return schemas.Response(success=True, data={
|
return schemas.Response(success=True, data={
|
||||||
|
|||||||
@@ -20,22 +20,42 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
class ManualTransferItem(BaseModel):
|
class ManualTransferItem(BaseModel):
|
||||||
fileitem: FileItem = None,
|
# 文件项
|
||||||
logid: Optional[int] = None,
|
fileitem: FileItem = None
|
||||||
target_storage: Optional[str] = None,
|
# 日志ID
|
||||||
target_path: Optional[str] = None,
|
logid: Optional[int] = None
|
||||||
tmdbid: Optional[int] = None,
|
# 目标存储
|
||||||
doubanid: Optional[str] = None,
|
target_storage: Optional[str] = None
|
||||||
type_name: Optional[str] = None,
|
# 目标路径
|
||||||
season: Optional[int] = None,
|
target_path: Optional[str] = None
|
||||||
transfer_type: Optional[str] = None,
|
# TMDB ID
|
||||||
episode_format: Optional[str] = None,
|
tmdbid: Optional[int] = None
|
||||||
episode_detail: Optional[str] = None,
|
# 豆瓣ID
|
||||||
episode_part: Optional[str] = None,
|
doubanid: Optional[str] = None
|
||||||
episode_offset: Optional[str] = None,
|
# 类型
|
||||||
min_filesize: Optional[int] = 0,
|
type_name: Optional[str] = None
|
||||||
scrape: bool = False,
|
# 季号
|
||||||
from_history: bool = False
|
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)
|
@router.get("/name", summary="查询整理后的名称", response_model=schemas.Response)
|
||||||
@@ -148,6 +168,8 @@ def manual_transfer(transer_item: ManualTransferItem,
|
|||||||
epformat=epformat,
|
epformat=epformat,
|
||||||
min_filesize=transer_item.min_filesize,
|
min_filesize=transer_item.min_filesize,
|
||||||
scrape=transer_item.scrape,
|
scrape=transer_item.scrape,
|
||||||
|
library_type_folder=transer_item.library_type_folder,
|
||||||
|
library_category_folder=transer_item.library_category_folder,
|
||||||
force=force
|
force=force
|
||||||
)
|
)
|
||||||
# 失败
|
# 失败
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
target_directory: TransferDirectoryConf = None,
|
target_directory: TransferDirectoryConf = None,
|
||||||
target_storage: str = None, target_path: Path = None,
|
target_storage: str = None, target_path: Path = None,
|
||||||
transfer_type: str = None, scrape: bool = None,
|
transfer_type: str = None, scrape: bool = None,
|
||||||
|
library_type_folder: bool = None, library_category_folder: bool = None,
|
||||||
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
||||||
"""
|
"""
|
||||||
文件转移
|
文件转移
|
||||||
@@ -396,6 +397,8 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:param target_path: 目标路径
|
:param target_path: 目标路径
|
||||||
:param transfer_type: 转移模式
|
:param transfer_type: 转移模式
|
||||||
:param scrape: 是否刮削元数据
|
:param scrape: 是否刮削元数据
|
||||||
|
:param library_type_folder: 是否按类型创建目录
|
||||||
|
:param library_category_folder: 是否按类别创建目录
|
||||||
:param episodes_info: 当前季的全部集信息
|
:param episodes_info: 当前季的全部集信息
|
||||||
:return: {path, target_path, message}
|
:return: {path, target_path, message}
|
||||||
"""
|
"""
|
||||||
@@ -404,6 +407,8 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
target_directory=target_directory,
|
target_directory=target_directory,
|
||||||
target_path=target_path, target_storage=target_storage,
|
target_path=target_path, target_storage=target_storage,
|
||||||
transfer_type=transfer_type, scrape=scrape,
|
transfer_type=transfer_type, scrape=scrape,
|
||||||
|
library_type_folder=library_type_folder,
|
||||||
|
library_category_folder=library_category_folder,
|
||||||
episodes_info=episodes_info)
|
episodes_info=episodes_info)
|
||||||
|
|
||||||
def transfer_completed(self, hashs: str, downloader: str = None) -> None:
|
def transfer_completed(self, hashs: str, downloader: str = None) -> None:
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
|||||||
"description": "更新站点Cookie",
|
"description": "更新站点Cookie",
|
||||||
"data": {}
|
"data": {}
|
||||||
},
|
},
|
||||||
|
"/site_statistic": {
|
||||||
|
"func": SiteChain().remote_refresh_userdatas,
|
||||||
|
"description": "站点数据统计",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
"/site_enable": {
|
"/site_enable": {
|
||||||
"func": SiteChain().remote_enable,
|
"func": SiteChain().remote_enable,
|
||||||
"description": "启用站点",
|
"description": "启用站点",
|
||||||
@@ -402,7 +407,7 @@ class CommandChain(ChainBase, metaclass=Singleton):
|
|||||||
channel=event_channel, source=event_source, userid=event_user)
|
channel=event_channel, source=event_source, userid=event_user)
|
||||||
|
|
||||||
@eventmanager.register(EventType.ModuleReload)
|
@eventmanager.register(EventType.ModuleReload)
|
||||||
def module_reload_event(self, event: ManagerEvent) -> None:
|
def module_reload_event(self, _: ManagerEvent) -> None:
|
||||||
"""
|
"""
|
||||||
注册模块重载事件
|
注册模块重载事件
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class DownloadChain(ChainBase):
|
|||||||
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
|
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
|
||||||
url=torrent_url,
|
url=torrent_url,
|
||||||
cookie=site_cookie,
|
cookie=site_cookie,
|
||||||
ua=torrent.site_ua,
|
ua=torrent.site_ua or settings.USER_AGENT,
|
||||||
proxy=torrent.site_proxy)
|
proxy=torrent.site_proxy)
|
||||||
|
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
@@ -204,10 +204,10 @@ class DownloadChain(ChainBase):
|
|||||||
def download_single(self, context: Context, torrent_file: Path = None,
|
def download_single(self, context: Context, torrent_file: Path = None,
|
||||||
episodes: Set[int] = None,
|
episodes: Set[int] = None,
|
||||||
channel: MessageChannel = None, source: str = None,
|
channel: MessageChannel = None, source: str = None,
|
||||||
|
downloader: str = None,
|
||||||
save_path: str = None,
|
save_path: str = None,
|
||||||
userid: Union[str, int] = None,
|
userid: Union[str, int] = None,
|
||||||
username: str = None,
|
username: str = None,
|
||||||
downloader: str = None,
|
|
||||||
media_category: str = None) -> Optional[str]:
|
media_category: str = None) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
下载及发送通知
|
下载及发送通知
|
||||||
@@ -216,15 +216,16 @@ class DownloadChain(ChainBase):
|
|||||||
:param episodes: 需要下载的集数
|
:param episodes: 需要下载的集数
|
||||||
:param channel: 通知渠道
|
:param channel: 通知渠道
|
||||||
:param source: 通知来源
|
:param source: 通知来源
|
||||||
|
:param downloader: 下载器
|
||||||
:param save_path: 保存路径
|
:param save_path: 保存路径
|
||||||
:param userid: 用户ID
|
:param userid: 用户ID
|
||||||
:param username: 调用下载的用户名/插件名
|
:param username: 调用下载的用户名/插件名
|
||||||
:param downloader: 下载器
|
|
||||||
:param media_category: 自定义媒体类别
|
:param media_category: 自定义媒体类别
|
||||||
"""
|
"""
|
||||||
_torrent = context.torrent_info
|
_torrent = context.torrent_info
|
||||||
_media = context.media_info
|
_media = context.media_info
|
||||||
_meta = context.meta_info
|
_meta = context.meta_info
|
||||||
|
_site_downloader = _torrent.site_downloader
|
||||||
|
|
||||||
# 补充完整的media数据
|
# 补充完整的media数据
|
||||||
if not _media.genre_ids:
|
if not _media.genre_ids:
|
||||||
@@ -251,35 +252,31 @@ class DownloadChain(ChainBase):
|
|||||||
|
|
||||||
# 下载目录
|
# 下载目录
|
||||||
if save_path:
|
if save_path:
|
||||||
# 有自定义下载目录时,尝试匹配目录配置
|
# 下载目录使用自定义的
|
||||||
dir_info = self.directoryhelper.get_dir(_media, src_path=Path(save_path))
|
|
||||||
else:
|
|
||||||
# 根据媒体信息查询下载目录配置
|
|
||||||
dir_info = self.directoryhelper.get_dir(_media)
|
|
||||||
|
|
||||||
# 拼装子目录
|
|
||||||
if dir_info:
|
|
||||||
# 一级目录
|
|
||||||
if not dir_info.media_type and dir_info.download_type_folder:
|
|
||||||
# 一级自动分类
|
|
||||||
download_dir = Path(dir_info.download_path) / _media.type.value
|
|
||||||
else:
|
|
||||||
# 一级不分类
|
|
||||||
download_dir = Path(dir_info.download_path)
|
|
||||||
|
|
||||||
# 二级目录
|
|
||||||
if not dir_info.media_category and dir_info.download_category_folder and _media and _media.category:
|
|
||||||
# 二级自动分类
|
|
||||||
download_dir = download_dir / _media.category
|
|
||||||
elif save_path:
|
|
||||||
# 自定义下载目录
|
|
||||||
download_dir = Path(save_path)
|
download_dir = Path(save_path)
|
||||||
else:
|
else:
|
||||||
# 未找到下载目录,且没有自定义下载目录
|
# 根据媒体信息查询下载目录配置
|
||||||
logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}")
|
dir_info = self.directoryhelper.get_dir(_media, storage="local", include_unsorted=True)
|
||||||
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
|
# 拼装子目录
|
||||||
title="下载失败", role="system")
|
if dir_info:
|
||||||
return None
|
# 一级目录
|
||||||
|
if not dir_info.media_type and dir_info.download_type_folder:
|
||||||
|
# 一级自动分类
|
||||||
|
download_dir = Path(dir_info.download_path) / _media.type.value
|
||||||
|
else:
|
||||||
|
# 一级不分类
|
||||||
|
download_dir = Path(dir_info.download_path)
|
||||||
|
|
||||||
|
# 二级目录
|
||||||
|
if not dir_info.media_category and dir_info.download_category_folder and _media and _media.category:
|
||||||
|
# 二级自动分类
|
||||||
|
download_dir = download_dir / _media.category
|
||||||
|
else:
|
||||||
|
# 未找到下载目录,且没有自定义下载目录
|
||||||
|
logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}")
|
||||||
|
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
|
||||||
|
title="下载失败", role="system")
|
||||||
|
return None
|
||||||
|
|
||||||
# 添加下载
|
# 添加下载
|
||||||
result: Optional[tuple] = self.download(content=content,
|
result: Optional[tuple] = self.download(content=content,
|
||||||
@@ -287,7 +284,7 @@ class DownloadChain(ChainBase):
|
|||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
download_dir=download_dir,
|
download_dir=download_dir,
|
||||||
category=_media.category,
|
category=_media.category,
|
||||||
downloader=downloader)
|
downloader=downloader or _site_downloader)
|
||||||
if result:
|
if result:
|
||||||
_downloader, _hash, error_msg = result
|
_downloader, _hash, error_msg = result
|
||||||
else:
|
else:
|
||||||
@@ -335,7 +332,7 @@ class DownloadChain(ChainBase):
|
|||||||
continue
|
continue
|
||||||
# 只处理视频格式
|
# 只处理视频格式
|
||||||
if not Path(file).suffix \
|
if not Path(file).suffix \
|
||||||
or Path(file).suffix not in settings.RMT_MEDIAEXT:
|
or Path(file).suffix.lower() not in settings.RMT_MEDIAEXT:
|
||||||
continue
|
continue
|
||||||
files_to_add.append({
|
files_to_add.append({
|
||||||
"download_hash": _hash,
|
"download_hash": _hash,
|
||||||
@@ -386,7 +383,8 @@ class DownloadChain(ChainBase):
|
|||||||
source: str = None,
|
source: str = None,
|
||||||
userid: str = None,
|
userid: str = None,
|
||||||
username: str = None,
|
username: str = None,
|
||||||
media_category: str = None
|
media_category: str = None,
|
||||||
|
downloader: str = None
|
||||||
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||||
"""
|
"""
|
||||||
根据缺失数据,自动种子列表中组合择优下载
|
根据缺失数据,自动种子列表中组合择优下载
|
||||||
@@ -398,6 +396,7 @@ class DownloadChain(ChainBase):
|
|||||||
:param userid: 用户ID
|
:param userid: 用户ID
|
||||||
:param username: 调用下载的用户名/插件名
|
:param username: 调用下载的用户名/插件名
|
||||||
:param media_category: 自定义媒体类别
|
:param media_category: 自定义媒体类别
|
||||||
|
:param downloader: 下载器
|
||||||
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}
|
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}
|
||||||
"""
|
"""
|
||||||
# 已下载的项目
|
# 已下载的项目
|
||||||
@@ -469,7 +468,7 @@ class DownloadChain(ChainBase):
|
|||||||
logger.info(f"开始下载电影 {context.torrent_info.title} ...")
|
logger.info(f"开始下载电影 {context.torrent_info.title} ...")
|
||||||
if self.download_single(context, save_path=save_path, channel=channel,
|
if self.download_single(context, save_path=save_path, channel=channel,
|
||||||
source=source, userid=userid, username=username,
|
source=source, userid=userid, username=username,
|
||||||
media_category=media_category):
|
media_category=media_category, downloader=downloader):
|
||||||
# 下载成功
|
# 下载成功
|
||||||
logger.info(f"{context.torrent_info.title} 添加下载成功")
|
logger.info(f"{context.torrent_info.title} 添加下载成功")
|
||||||
downloaded_list.append(context)
|
downloaded_list.append(context)
|
||||||
@@ -554,7 +553,8 @@ class DownloadChain(ChainBase):
|
|||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
media_category=media_category
|
media_category=media_category,
|
||||||
|
downloader=downloader,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 下载
|
# 下载
|
||||||
@@ -562,7 +562,8 @@ class DownloadChain(ChainBase):
|
|||||||
download_id = self.download_single(context, save_path=save_path,
|
download_id = self.download_single(context, save_path=save_path,
|
||||||
channel=channel, source=source,
|
channel=channel, source=source,
|
||||||
userid=userid, username=username,
|
userid=userid, username=username,
|
||||||
media_category=media_category)
|
media_category=media_category,
|
||||||
|
downloader=downloader)
|
||||||
|
|
||||||
if download_id:
|
if download_id:
|
||||||
# 下载成功
|
# 下载成功
|
||||||
@@ -633,7 +634,8 @@ class DownloadChain(ChainBase):
|
|||||||
download_id = self.download_single(context, save_path=save_path,
|
download_id = self.download_single(context, save_path=save_path,
|
||||||
channel=channel, source=source,
|
channel=channel, source=source,
|
||||||
userid=userid, username=username,
|
userid=userid, username=username,
|
||||||
media_category=media_category)
|
media_category=media_category,
|
||||||
|
downloader=downloader)
|
||||||
if download_id:
|
if download_id:
|
||||||
# 下载成功
|
# 下载成功
|
||||||
logger.info(f"{meta.title} 添加下载成功")
|
logger.info(f"{meta.title} 添加下载成功")
|
||||||
@@ -722,7 +724,8 @@ class DownloadChain(ChainBase):
|
|||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
media_category=media_category
|
media_category=media_category,
|
||||||
|
downloader=downloader
|
||||||
)
|
)
|
||||||
if not download_id:
|
if not download_id:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ from app.core.event import eventmanager, Event
|
|||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
from app.schemas import FileItem
|
||||||
from app.schemas.types import EventType, MediaType, ChainEventType
|
from app.schemas.types import EventType, MediaType, ChainEventType
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
recognize_lock = Lock()
|
recognize_lock = Lock()
|
||||||
|
scraping_lock = Lock()
|
||||||
|
scraping_files = []
|
||||||
|
|
||||||
|
|
||||||
class MediaChain(ChainBase, metaclass=Singleton):
|
class MediaChain(ChainBase, metaclass=Singleton):
|
||||||
@@ -301,12 +304,23 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
if not event:
|
if not event:
|
||||||
return
|
return
|
||||||
event_data = event.event_data or {}
|
event_data = event.event_data or {}
|
||||||
fileitem = event_data.get("fileitem")
|
fileitem: FileItem = event_data.get("fileitem")
|
||||||
meta = event_data.get("meta")
|
meta: MetaBase = event_data.get("meta")
|
||||||
mediainfo = event_data.get("mediainfo")
|
mediainfo: MediaInfo = event_data.get("mediainfo")
|
||||||
if not fileitem:
|
if not fileitem:
|
||||||
return
|
return
|
||||||
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
# 刮削锁
|
||||||
|
with scraping_lock:
|
||||||
|
if fileitem.path in scraping_files:
|
||||||
|
return
|
||||||
|
scraping_files.append(fileitem.path)
|
||||||
|
try:
|
||||||
|
# 执行刮削
|
||||||
|
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||||
|
finally:
|
||||||
|
# 释放锁
|
||||||
|
with scraping_lock:
|
||||||
|
scraping_files.remove(fileitem.path)
|
||||||
|
|
||||||
def scrape_metadata(self, fileitem: schemas.FileItem,
|
def scrape_metadata(self, fileitem: schemas.FileItem,
|
||||||
meta: MetaBase = None, mediainfo: MediaInfo = None,
|
meta: MetaBase = None, mediainfo: MediaInfo = None,
|
||||||
@@ -322,6 +336,20 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
:param overwrite: 是否覆盖已有文件
|
:param overwrite: 是否覆盖已有文件
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def is_bluray_folder(_fileitem: schemas.FileItem) -> bool:
|
||||||
|
"""
|
||||||
|
判断是否为原盘目录
|
||||||
|
"""
|
||||||
|
if not _fileitem or _fileitem.type != "dir":
|
||||||
|
return False
|
||||||
|
# 蓝光原盘目录必备的文件或文件夹
|
||||||
|
required_files = ['BDMV', 'CERTIFICATE']
|
||||||
|
# 检查目录下是否存在所需文件或文件夹
|
||||||
|
for item in self.storagechain.list_files(_fileitem):
|
||||||
|
if item.name in required_files:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def __list_files(_fileitem: schemas.FileItem):
|
def __list_files(_fileitem: schemas.FileItem):
|
||||||
"""
|
"""
|
||||||
列出下级文件
|
列出下级文件
|
||||||
@@ -337,13 +365,19 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
if not _fileitem or not _content or not _path:
|
if not _fileitem or not _content or not _path:
|
||||||
return
|
return
|
||||||
|
# 保存文件到临时目录
|
||||||
tmp_file = settings.TEMP_PATH / _path.name
|
tmp_file = settings.TEMP_PATH / _path.name
|
||||||
tmp_file.write_bytes(_content)
|
tmp_file.write_bytes(_content)
|
||||||
logger.info(f"保存文件:【{_fileitem.storage}】{_path}")
|
# 获取文件的父目录
|
||||||
_fileitem.path = str(_path.parent)
|
try:
|
||||||
self.storagechain.upload_file(fileitem=_fileitem, path=tmp_file)
|
item = self.storagechain.upload_file(fileitem=_fileitem, path=tmp_file, new_name=_path.name)
|
||||||
if tmp_file.exists():
|
if item:
|
||||||
tmp_file.unlink()
|
logger.info(f"已保存文件:{item.path}")
|
||||||
|
else:
|
||||||
|
logger.warn(f"文件保存失败:{item.path}")
|
||||||
|
finally:
|
||||||
|
if tmp_file.exists():
|
||||||
|
tmp_file.unlink()
|
||||||
|
|
||||||
def __download_image(_url: str) -> Optional[bytes]:
|
def __download_image(_url: str) -> Optional[bytes]:
|
||||||
"""
|
"""
|
||||||
@@ -376,25 +410,40 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
if mediainfo.type == MediaType.MOVIE:
|
if mediainfo.type == MediaType.MOVIE:
|
||||||
# 电影
|
# 电影
|
||||||
if fileitem.type == "file":
|
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
|
||||||
# 电影文件
|
# 电影文件
|
||||||
logger.info(f"正在生成电影nfo:{mediainfo.title_year} - {filepath.name}")
|
|
||||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||||
if not movie_nfo:
|
if not movie_nfo:
|
||||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||||
return
|
return
|
||||||
# 保存或上传nfo文件到上级目录
|
# 保存或上传nfo文件到上级目录
|
||||||
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
|
|
||||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||||
else:
|
else:
|
||||||
# 电影目录
|
# 电影目录
|
||||||
files = __list_files(_fileitem=fileitem)
|
if is_bluray_folder(fileitem):
|
||||||
for file in files:
|
# 原盘目录
|
||||||
self.scrape_metadata(fileitem=file,
|
nfo_path = filepath / (filepath.name + ".nfo")
|
||||||
meta=meta, mediainfo=mediainfo,
|
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||||
init_folder=False, parent=fileitem)
|
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)
|
||||||
|
for file in files:
|
||||||
|
self.scrape_metadata(fileitem=file,
|
||||||
|
meta=meta, mediainfo=mediainfo,
|
||||||
|
init_folder=False, parent=fileitem)
|
||||||
# 生成目录内图片文件
|
# 生成目录内图片文件
|
||||||
if init_folder:
|
if init_folder:
|
||||||
# 图片
|
# 图片
|
||||||
@@ -418,7 +467,12 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
# 电视剧
|
# 电视剧
|
||||||
if fileitem.type == "file":
|
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)
|
file_meta = MetaInfoPath(filepath)
|
||||||
if not file_meta.begin_episode:
|
if not file_meta.begin_episode:
|
||||||
logger.warn(f"{filepath.name} 无法识别文件集数!")
|
logger.warn(f"{filepath.name} 无法识别文件集数!")
|
||||||
@@ -434,10 +488,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
logger.warn(f"{filepath.name} nfo生成失败!")
|
logger.warn(f"{filepath.name} nfo生成失败!")
|
||||||
return
|
return
|
||||||
# 保存或上传nfo文件到上级目录
|
# 保存或上传nfo文件到上级目录
|
||||||
nfo_path = filepath.with_suffix(".nfo")
|
if not parent:
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
parent = self.storagechain.get_parent_item(fileitem)
|
||||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
|
||||||
return
|
|
||||||
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
||||||
# 获取集的图片
|
# 获取集的图片
|
||||||
image_dict = self.metadata_img(mediainfo=file_mediainfo,
|
image_dict = self.metadata_img(mediainfo=file_mediainfo,
|
||||||
@@ -452,6 +504,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
content = __download_image(image_url)
|
content = __download_image(image_url)
|
||||||
# 保存图片文件到当前目录
|
# 保存图片文件到当前目录
|
||||||
if content:
|
if content:
|
||||||
|
if not parent:
|
||||||
|
parent = self.storagechain.get_parent_item(fileitem)
|
||||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -466,17 +520,21 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
if init_folder:
|
if init_folder:
|
||||||
# 识别文件夹名称
|
# 识别文件夹名称
|
||||||
season_meta = MetaInfo(filepath.name)
|
season_meta = MetaInfo(filepath.name)
|
||||||
if season_meta.begin_season:
|
# 当前文件夹为Specials或者SPs时,设置为S0
|
||||||
|
if filepath.name in settings.RENAME_FORMAT_S0_NAMES:
|
||||||
|
season_meta.begin_season = 0
|
||||||
|
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):
|
||||||
|
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||||
|
return
|
||||||
# 当前目录有季号,生成季nfo
|
# 当前目录有季号,生成季nfo
|
||||||
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo, season=season_meta.begin_season)
|
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo, season=season_meta.begin_season)
|
||||||
if not season_nfo:
|
if not season_nfo:
|
||||||
logger.warn(f"无法生成电视剧季nfo文件:{meta.name}")
|
logger.warn(f"无法生成电视剧季nfo文件:{meta.name}")
|
||||||
return
|
return
|
||||||
# 写入nfo到根目录
|
# 写入nfo到根目录
|
||||||
nfo_path = filepath / "season.nfo"
|
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
|
||||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
|
||||||
return
|
|
||||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
|
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
|
||||||
# TMDB季poster图片
|
# TMDB季poster图片
|
||||||
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
|
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
|
||||||
@@ -489,21 +547,24 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
continue
|
continue
|
||||||
# 下载图片
|
# 下载图片
|
||||||
content = __download_image(image_url)
|
content = __download_image(image_url)
|
||||||
# 保存图片文件到当前目录
|
# 保存图片文件到剧集目录
|
||||||
if content:
|
if content:
|
||||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
if not parent:
|
||||||
|
parent = self.storagechain.get_parent_item(fileitem)
|
||||||
|
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||||
# 判断当前目录是不是剧集根目录
|
# 判断当前目录是不是剧集根目录
|
||||||
if season_meta.name:
|
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):
|
||||||
|
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||||
|
return
|
||||||
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
||||||
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||||
if not tv_nfo:
|
if not tv_nfo:
|
||||||
logger.warn(f"无法生成电视剧nfo文件:{meta.name}")
|
logger.warn(f"无法生成电视剧nfo文件:{meta.name}")
|
||||||
return
|
return
|
||||||
# 写入tvshow nfo到根目录
|
# 写入tvshow nfo到根目录
|
||||||
nfo_path = filepath / "tvshow.nfo"
|
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
|
||||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
|
||||||
return
|
|
||||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
|
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
|
||||||
# 生成目录图片
|
# 生成目录图片
|
||||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ from typing import List, Union, Optional, Generator
|
|||||||
|
|
||||||
from cachetools import cached, TTLCache
|
from cachetools import cached, TTLCache
|
||||||
|
|
||||||
from app import schemas
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.config import global_vars
|
from app.core.config import global_vars
|
||||||
from app.db.mediaserver_oper import MediaServerOper
|
from app.db.mediaserver_oper import MediaServerOper
|
||||||
from app.helper.service import ServiceConfigHelper
|
from app.helper.service import ServiceConfigHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
from app.schemas import MediaServerLibrary, MediaServerItem, MediaServerSeasonInfo, MediaServerPlayItem
|
||||||
|
|
||||||
lock = threading.Lock()
|
lock = threading.Lock()
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ class MediaServerChain(ChainBase):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.dboper = MediaServerOper()
|
self.dboper = MediaServerOper()
|
||||||
|
|
||||||
def librarys(self, server: str, username: str = None, hidden: bool = False) -> List[schemas.MediaServerLibrary]:
|
def librarys(self, server: str, username: str = None, hidden: bool = False) -> List[MediaServerLibrary]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器所有媒体库
|
获取媒体服务器所有媒体库
|
||||||
"""
|
"""
|
||||||
@@ -70,25 +70,25 @@ class MediaServerChain(ChainBase):
|
|||||||
yield from self.run_module("mediaserver_items", server=server, library_id=library_id,
|
yield from self.run_module("mediaserver_items", server=server, library_id=library_id,
|
||||||
start_index=start_index, limit=limit)
|
start_index=start_index, limit=limit)
|
||||||
|
|
||||||
def iteminfo(self, server: str, item_id: Union[str, int]) -> schemas.MediaServerItem:
|
def iteminfo(self, server: str, item_id: Union[str, int]) -> MediaServerItem:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器项目信息
|
获取媒体服务器项目信息
|
||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_iteminfo", server=server, item_id=item_id)
|
return self.run_module("mediaserver_iteminfo", server=server, item_id=item_id)
|
||||||
|
|
||||||
def episodes(self, server: str, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
def episodes(self, server: str, item_id: Union[str, int]) -> List[MediaServerSeasonInfo]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器剧集信息
|
获取媒体服务器剧集信息
|
||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
|
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
|
||||||
|
|
||||||
def playing(self, server: str, count: int = 20, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
def playing(self, server: str, count: int = 20, username: str = None) -> List[MediaServerPlayItem]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器正在播放信息
|
获取媒体服务器正在播放信息
|
||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_playing", count=count, server=server, username=username)
|
return self.run_module("mediaserver_playing", count=count, server=server, username=username)
|
||||||
|
|
||||||
def latest(self, server: str, count: int = 20, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
def latest(self, server: str, count: int = 20, username: str = None) -> List[MediaServerPlayItem]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器最新入库条目
|
获取媒体服务器最新入库条目
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -221,6 +221,12 @@ class SearchChain(ChainBase):
|
|||||||
key=ProgressKey.Search)
|
key=ProgressKey.Search)
|
||||||
if not torrent.title:
|
if not torrent.title:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 匹配订阅附加参数
|
||||||
|
if filter_params and not self.torrenthelper.filter_torrent(torrent_info=torrent,
|
||||||
|
filter_params=filter_params):
|
||||||
|
continue
|
||||||
|
|
||||||
# 识别元数据
|
# 识别元数据
|
||||||
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description,
|
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description,
|
||||||
custom_words=custom_words)
|
custom_words=custom_words)
|
||||||
@@ -234,11 +240,6 @@ class SearchChain(ChainBase):
|
|||||||
_match_torrents.append((torrent, torrent_meta))
|
_match_torrents.append((torrent, torrent_meta))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 匹配订阅附加参数
|
|
||||||
if filter_params and not self.torrenthelper.filter_torrent(torrent_info=torrent,
|
|
||||||
filter_params=filter_params):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 比对种子
|
# 比对种子
|
||||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||||
torrent_meta=torrent_meta,
|
torrent_meta=torrent_meta,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Tuple, Union
|
from typing import Optional, Tuple, Union, Dict
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
@@ -86,14 +86,22 @@ class SiteChain(ChainBase):
|
|||||||
f"{userdata.message_unread} 条新消息,请登陆查看",
|
f"{userdata.message_unread} 条新消息,请登陆查看",
|
||||||
link=site.get("url")
|
link=site.get("url")
|
||||||
))
|
))
|
||||||
|
# 低分享率警告
|
||||||
|
if userdata.ratio and float(userdata.ratio) < 1:
|
||||||
|
self.post_message(Notification(
|
||||||
|
mtype=NotificationType.SiteMessage,
|
||||||
|
title=f"【站点分享率低预警】",
|
||||||
|
text=f"站点 {site.get('name')} 分享率 {userdata.ratio},请注意!"
|
||||||
|
))
|
||||||
return userdata
|
return userdata
|
||||||
|
|
||||||
def refresh_userdatas(self) -> None:
|
def refresh_userdatas(self) -> Dict[str, SiteUserData]:
|
||||||
"""
|
"""
|
||||||
刷新所有站点的用户数据
|
刷新所有站点的用户数据
|
||||||
"""
|
"""
|
||||||
sites = self.siteshelper.get_indexers()
|
sites = self.siteshelper.get_indexers()
|
||||||
any_site_updated = False
|
any_site_updated = False
|
||||||
|
result = {}
|
||||||
for site in sites:
|
for site in sites:
|
||||||
if global_vars.is_system_stopped:
|
if global_vars.is_system_stopped:
|
||||||
return
|
return
|
||||||
@@ -101,10 +109,12 @@ class SiteChain(ChainBase):
|
|||||||
userdata = self.refresh_userdata(site)
|
userdata = self.refresh_userdata(site)
|
||||||
if userdata:
|
if userdata:
|
||||||
any_site_updated = True
|
any_site_updated = True
|
||||||
|
result[site.get("name")] = userdata
|
||||||
if any_site_updated:
|
if any_site_updated:
|
||||||
EventManager().send_event(EventType.SiteRefreshed, {
|
EventManager().send_event(EventType.SiteRefreshed, {
|
||||||
"site_id": "*"
|
"site_id": "*"
|
||||||
})
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
def is_special_site(self, domain: str) -> bool:
|
def is_special_site(self, domain: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -705,3 +715,66 @@ class SiteChain(ChainBase):
|
|||||||
source=source,
|
source=source,
|
||||||
title=f"【{site_info.name}】 Cookie&UA更新成功",
|
title=f"【{site_info.name}】 Cookie&UA更新成功",
|
||||||
userid=userid))
|
userid=userid))
|
||||||
|
|
||||||
|
def remote_refresh_userdatas(self, channel: MessageChannel,
|
||||||
|
userid: Union[str, int] = None, source: str = None):
|
||||||
|
"""
|
||||||
|
刷新所有站点用户数据
|
||||||
|
"""
|
||||||
|
logger.info("收到命令,开始刷新站点数据 ...")
|
||||||
|
self.post_message(Notification(
|
||||||
|
channel=channel,
|
||||||
|
source=source,
|
||||||
|
title="开始刷新站点数据 ...",
|
||||||
|
userid=userid
|
||||||
|
))
|
||||||
|
# 刷新站点数据
|
||||||
|
site_datas = self.refresh_userdatas()
|
||||||
|
if site_datas:
|
||||||
|
# 发送消息
|
||||||
|
messages = {}
|
||||||
|
# 总上传
|
||||||
|
incUploads = 0
|
||||||
|
# 总下载
|
||||||
|
incDownloads = 0
|
||||||
|
# 今天日期
|
||||||
|
today_date = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
for rand, site in enumerate(site_datas.keys()):
|
||||||
|
upload = int(site_datas[site].upload or 0)
|
||||||
|
download = int(site_datas[site].download or 0)
|
||||||
|
updated_date = site_datas[site].updated_day
|
||||||
|
if updated_date and updated_date != today_date:
|
||||||
|
updated_date = f"({updated_date})"
|
||||||
|
else:
|
||||||
|
updated_date = ""
|
||||||
|
|
||||||
|
if upload > 0 or download > 0:
|
||||||
|
incUploads += upload
|
||||||
|
incDownloads += download
|
||||||
|
messages[upload + (rand / 1000)] = (
|
||||||
|
f"【{site}】{updated_date}\n"
|
||||||
|
+ f"上传量:{StringUtils.str_filesize(upload)}\n"
|
||||||
|
+ f"下载量:{StringUtils.str_filesize(download)}\n"
|
||||||
|
+ "————————————"
|
||||||
|
)
|
||||||
|
if incDownloads or incUploads:
|
||||||
|
sorted_messages = [messages[key] for key in sorted(messages.keys(), reverse=True)]
|
||||||
|
sorted_messages.insert(0, f"【汇总】\n"
|
||||||
|
f"总上传:{StringUtils.str_filesize(incUploads)}\n"
|
||||||
|
f"总下载:{StringUtils.str_filesize(incDownloads)}\n"
|
||||||
|
f"————————————")
|
||||||
|
self.post_message(Notification(
|
||||||
|
channel=channel,
|
||||||
|
source=source,
|
||||||
|
title="【站点数据统计】",
|
||||||
|
text="\n".join(sorted_messages),
|
||||||
|
userid=userid
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
self.post_message(Notification(
|
||||||
|
channel=channel,
|
||||||
|
source=source,
|
||||||
|
title="没有刷新到任何站点数据!",
|
||||||
|
userid=userid
|
||||||
|
))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Optional, Tuple, List, Dict
|
|||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.helper.directory import DirectoryHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
|
|
||||||
@@ -13,6 +14,10 @@ class StorageChain(ChainBase):
|
|||||||
存储处理链
|
存储处理链
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.directoryhelper = DirectoryHelper()
|
||||||
|
|
||||||
def save_config(self, storage: str, conf: dict) -> None:
|
def save_config(self, storage: str, conf: dict) -> None:
|
||||||
"""
|
"""
|
||||||
保存存储配置
|
保存存储配置
|
||||||
@@ -57,13 +62,15 @@ class StorageChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("download_file", fileitem=fileitem, path=path)
|
return self.run_module("download_file", fileitem=fileitem, path=path)
|
||||||
|
|
||||||
def upload_file(self, fileitem: schemas.FileItem, path: Path) -> Optional[bool]:
|
def upload_file(self, fileitem: schemas.FileItem, path: Path,
|
||||||
|
new_name: str = None) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
上传文件
|
上传文件
|
||||||
:param fileitem: 保存目录项
|
:param fileitem: 保存目录项
|
||||||
:param path: 本地文件路径
|
:param path: 本地文件路径
|
||||||
|
:param new_name: 新文件名
|
||||||
"""
|
"""
|
||||||
return self.run_module("upload_file", fileitem=fileitem, path=path)
|
return self.run_module("upload_file", fileitem=fileitem, path=path, new_name=new_name)
|
||||||
|
|
||||||
def delete_file(self, fileitem: schemas.FileItem) -> Optional[bool]:
|
def delete_file(self, fileitem: schemas.FileItem) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
@@ -107,26 +114,52 @@ class StorageChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("support_transtype", storage=storage)
|
return self.run_module("support_transtype", storage=storage)
|
||||||
|
|
||||||
def delete_media_file(self, fileitem: schemas.FileItem, mtype: MediaType = None) -> bool:
|
def delete_media_file(self, fileitem: schemas.FileItem,
|
||||||
|
mtype: MediaType = None, delete_self: bool = True) -> bool:
|
||||||
"""
|
"""
|
||||||
删除媒体文件,以及不含媒体文件的目录
|
删除媒体文件,以及不含媒体文件的目录
|
||||||
"""
|
"""
|
||||||
state = self.delete_file(fileitem)
|
media_exts = settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT
|
||||||
if not state:
|
if fileitem.path == "/" or len(Path(fileitem.path).parts) <= 2:
|
||||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
logger.warn(f"【{fileitem.storage}】{fileitem.path} 根目录或一级目录不允许删除")
|
||||||
return False
|
return False
|
||||||
if fileitem.type == "dir":
|
if fileitem.type == "dir":
|
||||||
return True
|
# 本身是目录
|
||||||
# 上级目录
|
if self.any_files(fileitem, extensions=media_exts) is False:
|
||||||
if mtype and mtype == MediaType.TV:
|
logger.warn(f"【{fileitem.storage}】{fileitem.path} 不存在其它媒体文件,删除空目录")
|
||||||
dir_path = Path(fileitem.path).parent.parent
|
return self.delete_file(fileitem)
|
||||||
dir_item = self.get_file_item(storage=fileitem.storage, path=dir_path)
|
return False
|
||||||
|
elif delete_self:
|
||||||
|
# 本身是文件
|
||||||
|
logger.warn(f"正在删除【{fileitem.storage}】{fileitem.path}")
|
||||||
|
if not self.delete_file(fileitem):
|
||||||
|
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||||
|
return False
|
||||||
|
if mtype:
|
||||||
|
# 重命名格式
|
||||||
|
rename_format = settings.TV_RENAME_FORMAT \
|
||||||
|
if mtype == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||||
|
# 计算重命名中的文件夹层数
|
||||||
|
rename_format_level = len(rename_format.split("/")) - 1
|
||||||
|
if rename_format_level < 1:
|
||||||
|
return True
|
||||||
|
# 处理上级目录
|
||||||
|
dir_item = self.get_file_item(storage=fileitem.storage,
|
||||||
|
path=Path(fileitem.path).parents[rename_format_level - 1])
|
||||||
else:
|
else:
|
||||||
dir_item = self.get_parent_item(fileitem)
|
dir_item = self.get_parent_item(fileitem)
|
||||||
if dir_item:
|
if dir_item and len(Path(dir_item.path).parts) > 2:
|
||||||
|
# 如何目录是所有下载目录、媒体库目录的上级,则不处理
|
||||||
|
for d in self.directoryhelper.get_dirs():
|
||||||
|
if d.download_path and Path(d.download_path).is_relative_to(Path(dir_item.path)):
|
||||||
|
logger.debug(f"【{dir_item.storage}】{dir_item.path} 是下载目录本级或上级目录,不删除")
|
||||||
|
return True
|
||||||
|
if d.library_path and Path(d.library_path).is_relative_to(Path(dir_item.path)):
|
||||||
|
logger.debug(f"【{dir_item.storage}】{dir_item.path} 是媒体库目录本级或上级目录,不删除")
|
||||||
|
return True
|
||||||
# 不存在其他媒体文件,删除空目录
|
# 不存在其他媒体文件,删除空目录
|
||||||
if not self.any_files(dir_item, extensions=settings.RMT_MEDIAEXT):
|
if self.any_files(dir_item, extensions=media_exts) is False:
|
||||||
|
logger.warn(f"【{dir_item.storage}】{dir_item.path} 不存在其它媒体文件,删除空目录")
|
||||||
return self.delete_file(dir_item)
|
return self.delete_file(dir_item)
|
||||||
|
|
||||||
# 存在媒体文件,返回文件删除状态
|
return True
|
||||||
return state
|
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ class SubscribeChain(ChainBase):
|
|||||||
"search_imdbid") else kwargs.get("search_imdbid"),
|
"search_imdbid") else kwargs.get("search_imdbid"),
|
||||||
'sites': self.__get_default_subscribe_config(mediainfo.type, "sites") or None if not kwargs.get(
|
'sites': self.__get_default_subscribe_config(mediainfo.type, "sites") or None if not kwargs.get(
|
||||||
"sites") else kwargs.get("sites"),
|
"sites") else kwargs.get("sites"),
|
||||||
|
'downloader': self.__get_default_subscribe_config(mediainfo.type, "downloader") if not kwargs.get(
|
||||||
|
"downloader") else kwargs.get("downloader"),
|
||||||
'save_path': self.__get_default_subscribe_config(mediainfo.type, "save_path") if not kwargs.get(
|
'save_path': self.__get_default_subscribe_config(mediainfo.type, "save_path") if not kwargs.get(
|
||||||
"save_path") else kwargs.get("save_path")
|
"save_path") else kwargs.get("save_path")
|
||||||
})
|
})
|
||||||
@@ -363,10 +365,6 @@ class SubscribeChain(ChainBase):
|
|||||||
torrent_info = context.torrent_info
|
torrent_info = context.torrent_info
|
||||||
torrent_mediainfo = context.media_info
|
torrent_mediainfo = context.media_info
|
||||||
|
|
||||||
# 匹配订阅附加参数
|
|
||||||
if not self.torrenthelper.filter_torrent(torrent_info=torrent_info,
|
|
||||||
filter_params=self.get_params(subscribe)):
|
|
||||||
continue
|
|
||||||
# 洗版
|
# 洗版
|
||||||
if subscribe.best_version:
|
if subscribe.best_version:
|
||||||
# 洗版时,非整季不要
|
# 洗版时,非整季不要
|
||||||
@@ -394,7 +392,8 @@ class SubscribeChain(ChainBase):
|
|||||||
userid=subscribe.username,
|
userid=subscribe.username,
|
||||||
username=subscribe.username,
|
username=subscribe.username,
|
||||||
save_path=subscribe.save_path,
|
save_path=subscribe.save_path,
|
||||||
media_category=subscribe.media_category
|
media_category=subscribe.media_category,
|
||||||
|
downloader=subscribe.downloader,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 判断是否应完成订阅
|
# 判断是否应完成订阅
|
||||||
@@ -773,7 +772,8 @@ class SubscribeChain(ChainBase):
|
|||||||
userid=subscribe.username,
|
userid=subscribe.username,
|
||||||
username=subscribe.username,
|
username=subscribe.username,
|
||||||
save_path=subscribe.save_path,
|
save_path=subscribe.save_path,
|
||||||
media_category=subscribe.media_category)
|
media_category=subscribe.media_category,
|
||||||
|
downloader=subscribe.downloader)
|
||||||
# 判断是否要完成订阅
|
# 判断是否要完成订阅
|
||||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||||
downloads=downloads, lefts=lefts)
|
downloads=downloads, lefts=lefts)
|
||||||
@@ -1241,6 +1241,9 @@ class SubscribeChain(ChainBase):
|
|||||||
file_path=file.fullpath,
|
file_path=file.fullpath,
|
||||||
)
|
)
|
||||||
if subscribe.type == MediaType.TV.value:
|
if subscribe.type == MediaType.TV.value:
|
||||||
|
season_number = file_meta.begin_season
|
||||||
|
if season_number and season_number != subscribe.season:
|
||||||
|
continue
|
||||||
episode_number = file_meta.begin_episode
|
episode_number = file_meta.begin_episode
|
||||||
if episode_number and episodes.get(episode_number):
|
if episode_number and episodes.get(episode_number):
|
||||||
episodes[episode_number].download.append(file_info)
|
episodes[episode_number].download.append(file_info)
|
||||||
@@ -1278,6 +1281,9 @@ class SubscribeChain(ChainBase):
|
|||||||
file_path=fileitem.path,
|
file_path=fileitem.path,
|
||||||
)
|
)
|
||||||
if subscribe.type == MediaType.TV.value:
|
if subscribe.type == MediaType.TV.value:
|
||||||
|
season_number = file_meta.begin_season
|
||||||
|
if season_number and season_number != subscribe.season:
|
||||||
|
continue
|
||||||
episode_number = file_meta.begin_episode
|
episode_number = file_meta.begin_episode
|
||||||
if episode_number and episodes.get(episode_number):
|
if episode_number and episodes.get(episode_number):
|
||||||
episodes[episode_number].library.append(file_info)
|
episodes[episode_number].library.append(file_info)
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
site_ua=site.get("ua") or settings.USER_AGENT,
|
site_ua=site.get("ua") or settings.USER_AGENT,
|
||||||
site_proxy=site.get("proxy"),
|
site_proxy=site.get("proxy"),
|
||||||
site_order=site.get("pri"),
|
site_order=site.get("pri"),
|
||||||
|
site_downloader=site.get("downloader"),
|
||||||
title=item.get("title"),
|
title=item.get("title"),
|
||||||
enclosure=item.get("enclosure"),
|
enclosure=item.get("enclosure"),
|
||||||
page_url=item.get("link"),
|
page_url=item.get("link"),
|
||||||
@@ -174,7 +175,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
# 按pubdate降序排列
|
# 按pubdate降序排列
|
||||||
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
|
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
|
||||||
# 取前N条
|
# 取前N条
|
||||||
torrents = torrents[:settings.CACHE_CONF.get('refresh')]
|
torrents = torrents[:settings.CACHE_CONF["refresh"]]
|
||||||
if torrents:
|
if torrents:
|
||||||
# 过滤出没有处理过的种子
|
# 过滤出没有处理过的种子
|
||||||
torrents = [torrent for torrent in torrents
|
torrents = [torrent for torrent in torrents
|
||||||
@@ -214,8 +215,8 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
torrents_cache[domain].append(context)
|
torrents_cache[domain].append(context)
|
||||||
# 如果超过了限制条数则移除掉前面的
|
# 如果超过了限制条数则移除掉前面的
|
||||||
if len(torrents_cache[domain]) > settings.CACHE_CONF.get('torrents'):
|
if len(torrents_cache[domain]) > settings.CACHE_CONF["torrents"]:
|
||||||
torrents_cache[domain] = torrents_cache[domain][-settings.CACHE_CONF.get('torrents'):]
|
torrents_cache[domain] = torrents_cache[domain][-settings.CACHE_CONF["torrents"]:]
|
||||||
# 回收资源
|
# 回收资源
|
||||||
del torrents
|
del torrents
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.chain.tmdb import TmdbChain
|
|||||||
from app.core.config import settings, global_vars
|
from app.core.config import settings, global_vars
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.metainfo import MetaInfoPath
|
from app.core.metainfo import MetaInfoPath, MetaInfo
|
||||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||||
from app.db.models.downloadhistory import DownloadHistory
|
from app.db.models.downloadhistory import DownloadHistory
|
||||||
from app.db.models.transferhistory import TransferHistory
|
from app.db.models.transferhistory import TransferHistory
|
||||||
@@ -131,6 +131,7 @@ class TransferChain(ChainBase):
|
|||||||
extension=file_path.suffix.lstrip('.'),
|
extension=file_path.suffix.lstrip('.'),
|
||||||
),
|
),
|
||||||
mediainfo=mediainfo,
|
mediainfo=mediainfo,
|
||||||
|
downloader=torrent.downloader,
|
||||||
download_hash=torrent.hash,
|
download_hash=torrent.hash,
|
||||||
src_match=True
|
src_match=True
|
||||||
)
|
)
|
||||||
@@ -148,8 +149,9 @@ class TransferChain(ChainBase):
|
|||||||
target_directory: TransferDirectoryConf = None,
|
target_directory: TransferDirectoryConf = None,
|
||||||
target_storage: str = None, target_path: Path = None,
|
target_storage: str = None, target_path: Path = None,
|
||||||
transfer_type: str = None, scrape: bool = None,
|
transfer_type: str = None, scrape: bool = None,
|
||||||
season: int = None, epformat: EpisodeFormat = None,
|
library_type_folder: bool = None, library_category_folder: bool = None,
|
||||||
min_filesize: int = 0, download_hash: str = None,
|
season: int = None, epformat: EpisodeFormat = None, min_filesize: int = 0,
|
||||||
|
downloader: str = None, download_hash: str = None,
|
||||||
force: bool = False, src_match: bool = False) -> Tuple[bool, str]:
|
force: bool = False, src_match: bool = False) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
执行一个复杂目录的整理操作
|
执行一个复杂目录的整理操作
|
||||||
@@ -161,9 +163,12 @@ class TransferChain(ChainBase):
|
|||||||
:param target_path: 目标路径
|
:param target_path: 目标路径
|
||||||
:param transfer_type: 整理类型
|
:param transfer_type: 整理类型
|
||||||
:param scrape: 是否刮削元数据
|
:param scrape: 是否刮削元数据
|
||||||
|
:param library_type_folder: 媒体库类型子目录
|
||||||
|
:param library_category_folder: 媒体库类别子目录
|
||||||
:param season: 季
|
:param season: 季
|
||||||
:param epformat: 剧集格式
|
:param epformat: 剧集格式
|
||||||
:param min_filesize: 最小文件大小(MB)
|
:param min_filesize: 最小文件大小(MB)
|
||||||
|
:param downloader: 下载器
|
||||||
:param download_hash: 下载记录hash
|
:param download_hash: 下载记录hash
|
||||||
:param force: 是否强制整理
|
:param force: 是否强制整理
|
||||||
:param src_match: 是否源目录匹配
|
:param src_match: 是否源目录匹配
|
||||||
@@ -184,8 +189,6 @@ class TransferChain(ChainBase):
|
|||||||
|
|
||||||
# 汇总季集清单
|
# 汇总季集清单
|
||||||
season_episodes: Dict[Tuple, List[int]] = {}
|
season_episodes: Dict[Tuple, List[int]] = {}
|
||||||
# 汇总元数据
|
|
||||||
metas: Dict[Tuple, MetaBase] = {}
|
|
||||||
# 汇总媒体信息
|
# 汇总媒体信息
|
||||||
medias: Dict[Tuple, MediaInfo] = {}
|
medias: Dict[Tuple, MediaInfo] = {}
|
||||||
# 汇总整理信息
|
# 汇总整理信息
|
||||||
@@ -390,25 +393,35 @@ class TransferChain(ChainBase):
|
|||||||
|
|
||||||
# 查询整理目标目录
|
# 查询整理目标目录
|
||||||
if not target_directory:
|
if not target_directory:
|
||||||
if target_path:
|
if src_match:
|
||||||
target_directory = self.directoryhelper.get_dir(file_mediainfo,
|
# 按源目录匹配,以便找到更合适的目录配置
|
||||||
storage=target_storage, dest_path=target_path)
|
dir_info = self.directoryhelper.get_dir(media=file_mediainfo,
|
||||||
elif src_match:
|
storage=file_item.storage,
|
||||||
target_directory = self.directoryhelper.get_dir(file_mediainfo,
|
src_path=file_path,
|
||||||
storage=file_item.storage, src_path=file_path)
|
target_storage=target_storage)
|
||||||
|
elif target_path:
|
||||||
|
# 指定目标路径,`手动整理`场景下使用,忽略源目录匹配,使用指定目录匹配
|
||||||
|
dir_info = self.directoryhelper.get_dir(media=file_mediainfo,
|
||||||
|
dest_path=target_path,
|
||||||
|
target_storage=target_storage)
|
||||||
else:
|
else:
|
||||||
target_directory = self.directoryhelper.get_dir(file_mediainfo)
|
# 未指定目标路径,根据媒体信息获取目标目录
|
||||||
|
dir_info = self.directoryhelper.get_dir(file_mediainfo,
|
||||||
|
storage=file_item.storage,
|
||||||
|
target_storage=target_storage)
|
||||||
|
|
||||||
# 执行整理
|
# 执行整理
|
||||||
transferinfo: TransferInfo = self.transfer(fileitem=file_item,
|
transferinfo: TransferInfo = self.transfer(fileitem=file_item,
|
||||||
meta=file_meta,
|
meta=file_meta,
|
||||||
mediainfo=file_mediainfo,
|
mediainfo=file_mediainfo,
|
||||||
target_directory=target_directory,
|
target_directory=target_directory or dir_info,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
target_path=target_path,
|
target_path=target_path,
|
||||||
transfer_type=transfer_type,
|
transfer_type=transfer_type,
|
||||||
episodes_info=episodes_info,
|
episodes_info=episodes_info,
|
||||||
scrape=scrape)
|
scrape=scrape,
|
||||||
|
library_type_folder=library_type_folder,
|
||||||
|
library_category_folder=library_category_folder)
|
||||||
if not transferinfo:
|
if not transferinfo:
|
||||||
logger.error("文件整理模块运行失败")
|
logger.error("文件整理模块运行失败")
|
||||||
return False, "文件整理模块运行失败"
|
return False, "文件整理模块运行失败"
|
||||||
@@ -444,7 +457,6 @@ class TransferChain(ChainBase):
|
|||||||
mkey = (file_mediainfo.tmdb_id, file_meta.begin_season)
|
mkey = (file_mediainfo.tmdb_id, file_meta.begin_season)
|
||||||
if mkey not in medias:
|
if mkey not in medias:
|
||||||
# 新增信息
|
# 新增信息
|
||||||
metas[mkey] = file_meta
|
|
||||||
medias[mkey] = file_mediainfo
|
medias[mkey] = file_mediainfo
|
||||||
season_episodes[mkey] = file_meta.episode_list
|
season_episodes[mkey] = file_meta.episode_list
|
||||||
transfers[mkey] = transferinfo
|
transfers[mkey] = transferinfo
|
||||||
@@ -468,6 +480,15 @@ class TransferChain(ChainBase):
|
|||||||
transferinfo=transferinfo
|
transferinfo=transferinfo
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 整理完成事件
|
||||||
|
self.eventmanager.send_event(EventType.TransferComplete, {
|
||||||
|
'meta': file_meta,
|
||||||
|
'mediainfo': file_mediainfo,
|
||||||
|
'transferinfo': transferinfo,
|
||||||
|
'downloader': downloader,
|
||||||
|
'download_hash': download_hash,
|
||||||
|
})
|
||||||
|
|
||||||
# 更新进度
|
# 更新进度
|
||||||
processed_num += 1
|
processed_num += 1
|
||||||
self.progress.update(value=processed_num / total_num * 100,
|
self.progress.update(value=processed_num / total_num * 100,
|
||||||
@@ -480,8 +501,9 @@ class TransferChain(ChainBase):
|
|||||||
|
|
||||||
# 执行后续处理
|
# 执行后续处理
|
||||||
for mkey, media in medias.items():
|
for mkey, media in medias.items():
|
||||||
transfer_meta = metas[mkey]
|
|
||||||
transfer_info = transfers[mkey]
|
transfer_info = transfers[mkey]
|
||||||
|
transfer_meta = MetaInfo(transfer_info.target_diritem.name)
|
||||||
|
transfer_meta.begin_season = mkey[1]
|
||||||
# 发送通知
|
# 发送通知
|
||||||
if transfer_info.need_notify:
|
if transfer_info.need_notify:
|
||||||
se_str = None
|
se_str = None
|
||||||
@@ -498,29 +520,16 @@ class TransferChain(ChainBase):
|
|||||||
'mediainfo': media,
|
'mediainfo': media,
|
||||||
'fileitem': transfer_info.target_diritem
|
'fileitem': transfer_info.target_diritem
|
||||||
})
|
})
|
||||||
# 整理完成事件
|
|
||||||
self.eventmanager.send_event(EventType.TransferComplete, {
|
|
||||||
'meta': transfer_meta,
|
|
||||||
'mediainfo': media,
|
|
||||||
'transferinfo': transfer_info,
|
|
||||||
'download_hash': download_hash,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 移动模式处理
|
# 移动模式处理
|
||||||
if all_success and current_transfer_type in ["move"]:
|
if all_success and current_transfer_type in ["move"]:
|
||||||
# 下载器hash
|
# 下载器hash
|
||||||
if download_hash:
|
if download_hash:
|
||||||
if self.remove_torrents(download_hash):
|
if self.remove_torrents(download_hash, downloader=downloader):
|
||||||
logger.info(f"移动模式删除种子成功:{download_hash} ")
|
logger.info(f"移动模式删除种子成功:{download_hash} ")
|
||||||
# 删除残留目录
|
# 删除残留目录
|
||||||
if fileitem:
|
if fileitem:
|
||||||
if fileitem.type == "dir":
|
self.storagechain.delete_media_file(fileitem, delete_self=False)
|
||||||
folder_item = fileitem
|
|
||||||
else:
|
|
||||||
folder_item = self.storagechain.get_parent_item(fileitem)
|
|
||||||
if folder_item and not self.storagechain.any_files(folder_item, extensions=settings.RMT_MEDIAEXT):
|
|
||||||
logger.warn(f"删除残留空文件夹:【{folder_item.storage}】{folder_item.path}")
|
|
||||||
self.storagechain.delete_file(folder_item)
|
|
||||||
|
|
||||||
# 结束进度
|
# 结束进度
|
||||||
logger.info(f"{fileitem.path} 整理完成,共 {total_num} 个文件,"
|
logger.info(f"{fileitem.path} 整理完成,共 {total_num} 个文件,"
|
||||||
@@ -684,6 +693,8 @@ class TransferChain(ChainBase):
|
|||||||
epformat: EpisodeFormat = None,
|
epformat: EpisodeFormat = None,
|
||||||
min_filesize: int = 0,
|
min_filesize: int = 0,
|
||||||
scrape: bool = None,
|
scrape: bool = None,
|
||||||
|
library_type_folder: bool = None,
|
||||||
|
library_category_folder: bool = None,
|
||||||
force: bool = False) -> Tuple[bool, Union[str, list]]:
|
force: bool = False) -> Tuple[bool, Union[str, list]]:
|
||||||
"""
|
"""
|
||||||
手动整理,支持复杂条件,带进度显示
|
手动整理,支持复杂条件,带进度显示
|
||||||
@@ -698,6 +709,8 @@ class TransferChain(ChainBase):
|
|||||||
:param epformat: 剧集格式
|
:param epformat: 剧集格式
|
||||||
:param min_filesize: 最小文件大小(MB)
|
:param min_filesize: 最小文件大小(MB)
|
||||||
:param scrape: 是否刮削元数据
|
:param scrape: 是否刮削元数据
|
||||||
|
:param library_type_folder: 是否按类型建立目录
|
||||||
|
:param library_category_folder: 是否按类别建立目录
|
||||||
:param force: 是否强制整理
|
:param force: 是否强制整理
|
||||||
"""
|
"""
|
||||||
logger.info(f"手动整理:{fileitem.path} ...")
|
logger.info(f"手动整理:{fileitem.path} ...")
|
||||||
@@ -726,6 +739,8 @@ class TransferChain(ChainBase):
|
|||||||
epformat=epformat,
|
epformat=epformat,
|
||||||
min_filesize=min_filesize,
|
min_filesize=min_filesize,
|
||||||
scrape=scrape,
|
scrape=scrape,
|
||||||
|
library_type_folder=library_type_folder,
|
||||||
|
library_category_folder=library_category_folder,
|
||||||
force=force,
|
force=force,
|
||||||
)
|
)
|
||||||
if not state:
|
if not state:
|
||||||
@@ -744,6 +759,8 @@ class TransferChain(ChainBase):
|
|||||||
epformat=epformat,
|
epformat=epformat,
|
||||||
min_filesize=min_filesize,
|
min_filesize=min_filesize,
|
||||||
scrape=scrape,
|
scrape=scrape,
|
||||||
|
library_type_folder=library_type_folder,
|
||||||
|
library_category_folder=library_category_folder,
|
||||||
force=force)
|
force=force)
|
||||||
return state, errmsg
|
return state, errmsg
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Tuple, Type
|
from typing import Any, Dict, List, Optional, Tuple, Type
|
||||||
|
|
||||||
from dotenv import set_key
|
from dotenv import set_key
|
||||||
from pydantic import BaseModel, BaseSettings, validator
|
from pydantic import BaseModel, BaseSettings, validator, Field
|
||||||
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
@@ -36,7 +36,7 @@ class ConfigModel(BaseModel):
|
|||||||
# RESOURCE密钥
|
# RESOURCE密钥
|
||||||
RESOURCE_SECRET_KEY: str = secrets.token_urlsafe(32)
|
RESOURCE_SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||||
# 允许的域名
|
# 允许的域名
|
||||||
ALLOWED_HOSTS: list = ["*"]
|
ALLOWED_HOSTS: list = Field(default_factory=lambda: ["*"])
|
||||||
# TOKEN过期时间
|
# TOKEN过期时间
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
||||||
# RESOURCE_TOKEN过期时间
|
# RESOURCE_TOKEN过期时间
|
||||||
@@ -114,29 +114,39 @@ class ConfigModel(BaseModel):
|
|||||||
# 是否启用DOH解析域名
|
# 是否启用DOH解析域名
|
||||||
DOH_ENABLE: bool = True
|
DOH_ENABLE: bool = True
|
||||||
# 使用 DOH 解析的域名列表
|
# 使用 DOH 解析的域名列表
|
||||||
DOH_DOMAINS: str = "api.themoviedb.org,api.tmdb.org,webservice.fanart.tv,api.github.com,github.com,raw.githubusercontent.com,api.telegram.org"
|
DOH_DOMAINS: str = ("api.themoviedb.org,"
|
||||||
|
"api.tmdb.org,"
|
||||||
|
"webservice.fanart.tv,"
|
||||||
|
"api.github.com,"
|
||||||
|
"github.com,"
|
||||||
|
"raw.githubusercontent.com,"
|
||||||
|
"api.telegram.org")
|
||||||
# DOH 解析服务器列表
|
# DOH 解析服务器列表
|
||||||
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
|
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
|
||||||
# 支持的后缀格式
|
# 支持的后缀格式
|
||||||
RMT_MEDIAEXT: list = ['.mp4', '.mkv', '.ts', '.iso',
|
RMT_MEDIAEXT: list = Field(
|
||||||
'.rmvb', '.avi', '.mov', '.mpeg',
|
default_factory=lambda: ['.mp4', '.mkv', '.ts', '.iso',
|
||||||
'.mpg', '.wmv', '.3gp', '.asf',
|
'.rmvb', '.avi', '.mov', '.mpeg',
|
||||||
'.m4v', '.flv', '.m2ts', '.strm',
|
'.mpg', '.wmv', '.3gp', '.asf',
|
||||||
'.tp', '.f4v']
|
'.m4v', '.flv', '.m2ts', '.strm',
|
||||||
|
'.tp', '.f4v']
|
||||||
|
)
|
||||||
# 支持的字幕文件后缀格式
|
# 支持的字幕文件后缀格式
|
||||||
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa', '.sup']
|
RMT_SUBEXT: list = Field(default_factory=lambda: ['.srt', '.ass', '.ssa', '.sup'])
|
||||||
# 支持的音轨文件后缀格式
|
# 支持的音轨文件后缀格式
|
||||||
RMT_AUDIO_TRACK_EXT: list = ['.mka']
|
RMT_AUDIO_TRACK_EXT: list = Field(default_factory=lambda: ['.mka'])
|
||||||
# 音轨文件后缀格式
|
# 音轨文件后缀格式
|
||||||
RMT_AUDIOEXT: list = ['.aac', '.ac3', '.amr', '.caf', '.cda', '.dsf',
|
RMT_AUDIOEXT: list = Field(
|
||||||
'.dff', '.kar', '.m4a', '.mp1', '.mp2', '.mp3',
|
default_factory=lambda: ['.aac', '.ac3', '.amr', '.caf', '.cda', '.dsf',
|
||||||
'.mid', '.mod', '.mka', '.mpc', '.nsf', '.ogg',
|
'.dff', '.kar', '.m4a', '.mp1', '.mp2', '.mp3',
|
||||||
'.pcm', '.rmi', '.s3m', '.snd', '.spx', '.tak',
|
'.mid', '.mod', '.mka', '.mpc', '.nsf', '.ogg',
|
||||||
'.tta', '.vqf', '.wav', '.wma',
|
'.pcm', '.rmi', '.s3m', '.snd', '.spx', '.tak',
|
||||||
'.aifc', '.aiff', '.alac', '.adif', '.adts',
|
'.tta', '.vqf', '.wav', '.wma',
|
||||||
'.flac', '.midi', '.opus', '.sfalc']
|
'.aifc', '.aiff', '.alac', '.adif', '.adts',
|
||||||
|
'.flac', '.midi', '.opus', '.sfalc']
|
||||||
|
)
|
||||||
# 下载器临时文件后缀
|
# 下载器临时文件后缀
|
||||||
DOWNLOAD_TMPEXT: list = ['.!qB', '.part']
|
DOWNLOAD_TMPEXT: list = Field(default_factory=lambda: ['.!qb', '.part'])
|
||||||
# 媒体服务器同步间隔(小时)
|
# 媒体服务器同步间隔(小时)
|
||||||
MEDIASERVER_SYNC_INTERVAL: int = 6
|
MEDIASERVER_SYNC_INTERVAL: int = 6
|
||||||
# 订阅模式
|
# 订阅模式
|
||||||
@@ -189,7 +199,10 @@ class ConfigModel(BaseModel):
|
|||||||
# 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目
|
# 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目
|
||||||
MP_SERVER_HOST: str = "https://movie-pilot.org"
|
MP_SERVER_HOST: str = "https://movie-pilot.org"
|
||||||
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
||||||
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins,https://github.com/thsrite/MoviePilot-Plugins,https://github.com/honue/MoviePilot-Plugins,https://github.com/InfinityPacer/MoviePilot-Plugins"
|
PLUGIN_MARKET: str = ("https://github.com/jxxghp/MoviePilot-Plugins,"
|
||||||
|
"https://github.com/thsrite/MoviePilot-Plugins,"
|
||||||
|
"https://github.com/honue/MoviePilot-Plugins,"
|
||||||
|
"https://github.com/InfinityPacer/MoviePilot-Plugins")
|
||||||
# 插件安装数据共享
|
# 插件安装数据共享
|
||||||
PLUGIN_STATISTIC_SHARE: bool = True
|
PLUGIN_STATISTIC_SHARE: bool = True
|
||||||
# 是否开启插件热加载
|
# 是否开启插件热加载
|
||||||
@@ -206,11 +219,27 @@ class ConfigModel(BaseModel):
|
|||||||
BIG_MEMORY_MODE: bool = False
|
BIG_MEMORY_MODE: bool = False
|
||||||
# 全局图片缓存,将媒体图片缓存到本地
|
# 全局图片缓存,将媒体图片缓存到本地
|
||||||
GLOBAL_IMAGE_CACHE: bool = False
|
GLOBAL_IMAGE_CACHE: bool = False
|
||||||
|
# 是否启用编码探测的性能模式
|
||||||
|
ENCODING_DETECTION_PERFORMANCE_MODE: bool = True
|
||||||
|
# 编码探测的最低置信度阈值
|
||||||
|
ENCODING_DETECTION_MIN_CONFIDENCE: float = 0.8
|
||||||
# 允许的图片缓存域名
|
# 允许的图片缓存域名
|
||||||
SECURITY_IMAGE_DOMAINS: List[str] = ["image.tmdb.org", "static-mdb.v.geilijiasu.com", "doubanio.com", "lain.bgm.tv",
|
SECURITY_IMAGE_DOMAINS: List[str] = Field(
|
||||||
"raw.githubusercontent.com", "github.com"]
|
default_factory=lambda: ["image.tmdb.org",
|
||||||
|
"static-mdb.v.geilijiasu.com",
|
||||||
|
"doubanio.com",
|
||||||
|
"lain.bgm.tv",
|
||||||
|
"raw.githubusercontent.com",
|
||||||
|
"github.com"]
|
||||||
|
)
|
||||||
# 允许的图片文件后缀格式
|
# 允许的图片文件后缀格式
|
||||||
SECURITY_IMAGE_SUFFIXES: List[str] = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"]
|
SECURITY_IMAGE_SUFFIXES: List[str] = Field(
|
||||||
|
default_factory=lambda: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"]
|
||||||
|
)
|
||||||
|
# 重命名时支持的S0别名
|
||||||
|
RENAME_FORMAT_S0_NAMES: List[str] = Field(
|
||||||
|
default_factory=lambda: ["Specials", "SPs"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings, ConfigModel):
|
class Settings(BaseSettings, ConfigModel):
|
||||||
@@ -345,10 +374,9 @@ class Settings(BaseSettings, ConfigModel):
|
|||||||
logger.warning(message)
|
logger.warning(message)
|
||||||
|
|
||||||
if field.name in os.environ:
|
if field.name in os.environ:
|
||||||
if is_converted:
|
message = f"配置项 '{field.name}' 已在环境变量中设置,请手动更新以保持一致性"
|
||||||
message = f"配置项 '{field.name}' 已在环境变量中设置,请手动更新以保持一致性"
|
logger.warning(message)
|
||||||
logger.warning(message)
|
return False, message
|
||||||
return False, message
|
|
||||||
else:
|
else:
|
||||||
set_key(SystemUtils.get_env_path(), field.name, str(converted_value) if converted_value is not None else "")
|
set_key(SystemUtils.get_env_path(), field.name, str(converted_value) if converted_value is not None else "")
|
||||||
if is_converted:
|
if is_converted:
|
||||||
@@ -372,7 +400,7 @@ class Settings(BaseSettings, ConfigModel):
|
|||||||
field.default, key)
|
field.default, key)
|
||||||
# 如果没有抛出异常,则统一使用 converted_value 进行更新
|
# 如果没有抛出异常,则统一使用 converted_value 进行更新
|
||||||
if needs_update or str(value) != str(converted_value):
|
if needs_update or str(value) != str(converted_value):
|
||||||
success, message = self.update_env_config(field, original_value, converted_value)
|
success, message = self.update_env_config(field, value, converted_value)
|
||||||
# 仅成功更新配置时,才更新内存
|
# 仅成功更新配置时,才更新内存
|
||||||
if success:
|
if success:
|
||||||
setattr(self, key, converted_value)
|
setattr(self, key, converted_value)
|
||||||
@@ -437,22 +465,32 @@ class Settings(BaseSettings, ConfigModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def CACHE_CONF(self):
|
def CACHE_CONF(self):
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"torrents": "缓存种子数量",
|
||||||
|
"refresh": "订阅刷新处理数量",
|
||||||
|
"tmdb": "TMDB请求缓存数量",
|
||||||
|
"douban": "豆瓣请求缓存数量",
|
||||||
|
"fanart": "Fanart请求缓存数量",
|
||||||
|
"meta": "元数据缓存过期时间(秒)"
|
||||||
|
}
|
||||||
|
"""
|
||||||
if self.BIG_MEMORY_MODE:
|
if self.BIG_MEMORY_MODE:
|
||||||
return {
|
return {
|
||||||
|
"torrents": 200,
|
||||||
|
"refresh": 100,
|
||||||
"tmdb": 1024,
|
"tmdb": 1024,
|
||||||
"refresh": 50,
|
|
||||||
"torrents": 100,
|
|
||||||
"douban": 512,
|
"douban": 512,
|
||||||
"fanart": 512,
|
"fanart": 512,
|
||||||
"meta": (self.META_CACHE_EXPIRE or 168) * 3600
|
"meta": (self.META_CACHE_EXPIRE or 24) * 3600
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
"torrents": 100,
|
||||||
|
"refresh": 50,
|
||||||
"tmdb": 256,
|
"tmdb": 256,
|
||||||
"refresh": 30,
|
|
||||||
"torrents": 50,
|
|
||||||
"douban": 256,
|
"douban": 256,
|
||||||
"fanart": 128,
|
"fanart": 128,
|
||||||
"meta": (self.META_CACHE_EXPIRE or 72) * 3600
|
"meta": (self.META_CACHE_EXPIRE or 2) * 3600
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class TorrentInfo:
|
|||||||
site_proxy: bool = False
|
site_proxy: bool = False
|
||||||
# 站点优先级
|
# 站点优先级
|
||||||
site_order: int = 0
|
site_order: int = 0
|
||||||
|
# 站点下载器
|
||||||
|
site_downloader: str = None
|
||||||
# 种子名称
|
# 种子名称
|
||||||
title: str = None
|
title: str = None
|
||||||
# 种子副标题
|
# 种子副标题
|
||||||
|
|||||||
@@ -233,23 +233,29 @@ class EventManager(metaclass=Singleton):
|
|||||||
可视化所有事件处理器,包括是否被禁用的状态
|
可视化所有事件处理器,包括是否被禁用的状态
|
||||||
:return: 处理器列表,包含事件类型、处理器标识符、优先级(如果有)和状态
|
:return: 处理器列表,包含事件类型、处理器标识符、优先级(如果有)和状态
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def parse_handler_data(data):
|
||||||
|
"""
|
||||||
|
解析处理器数据,判断是否包含优先级
|
||||||
|
:param data: 订阅者数据,可能是元组或单一值
|
||||||
|
:return: (priority, handler),若没有优先级则返回 (None, handler)
|
||||||
|
"""
|
||||||
|
if isinstance(data, tuple) and len(data) == 2:
|
||||||
|
return data
|
||||||
|
return None, data
|
||||||
|
|
||||||
handler_info = []
|
handler_info = []
|
||||||
# 统一处理广播事件和链式事件
|
# 统一处理广播事件和链式事件
|
||||||
for event_type, subscribers in {**self.__broadcast_subscribers, **self.__chain_subscribers}.items():
|
for event_type, subscribers in {**self.__broadcast_subscribers, **self.__chain_subscribers}.items():
|
||||||
for handler_data in subscribers:
|
for handler_identifier, handler_data in subscribers.items():
|
||||||
if isinstance(subscribers, dict):
|
# 解析优先级和处理器
|
||||||
priority, handler = handler_data
|
priority, handler = parse_handler_data(handler_data)
|
||||||
else:
|
|
||||||
priority = None
|
|
||||||
handler = handler_data
|
|
||||||
# 获取处理器的唯一标识符
|
|
||||||
handler_id = self.__get_handler_identifier(handler)
|
|
||||||
# 检查处理器的启用状态
|
# 检查处理器的启用状态
|
||||||
status = "enabled" if self.__is_handler_enabled(handler) else "disabled"
|
status = "enabled" if self.__is_handler_enabled(handler) else "disabled"
|
||||||
# 构建处理器信息字典
|
# 构建处理器信息字典
|
||||||
handler_dict = {
|
handler_dict = {
|
||||||
"event_type": event_type.value,
|
"event_type": event_type.value,
|
||||||
"handler_identifier": handler_id,
|
"handler_identifier": handler_identifier,
|
||||||
"status": status
|
"status": status
|
||||||
}
|
}
|
||||||
if priority is not None:
|
if priority is not None:
|
||||||
@@ -499,11 +505,18 @@ class EventManager(metaclass=Singleton):
|
|||||||
def decorator(f: Callable):
|
def decorator(f: Callable):
|
||||||
# 将输入的事件类型统一转换为列表格式
|
# 将输入的事件类型统一转换为列表格式
|
||||||
if isinstance(etype, list):
|
if isinstance(etype, list):
|
||||||
event_list = etype # 传入的已经是列表,直接使用
|
# 传入的已经是列表,直接使用
|
||||||
|
event_list = etype
|
||||||
|
elif etype is EventType:
|
||||||
|
# 订阅所有事件
|
||||||
|
event_list = []
|
||||||
|
for et in etype:
|
||||||
|
event_list.append(et)
|
||||||
else:
|
else:
|
||||||
event_list = [etype] # 不是列表则包裹成单一元素的列表
|
# 不是列表则包裹成单一元素的列表
|
||||||
|
event_list = [etype]
|
||||||
|
|
||||||
# 遍历列表,处理每个事件类型
|
# 遍历列表,处理每个事件类型
|
||||||
for event in event_list:
|
for event in event_list:
|
||||||
if isinstance(event, (EventType, ChainEventType)):
|
if isinstance(event, (EventType, ChainEventType)):
|
||||||
self.add_event_listener(event, f)
|
self.add_event_listener(event, f)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class MetaVideo(MetaBase):
|
|||||||
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
|
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
|
||||||
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
|
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
|
||||||
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
|
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
|
||||||
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$|^REPACK$"
|
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$"
|
||||||
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$"
|
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
|
||||||
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
|
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
|
||||||
_name_no_begin_re = r"^[\[【].+?[\]】]"
|
_name_no_begin_re = r"^[\[【].+?[\]】]"
|
||||||
_name_no_chinese_re = r".*版|.*字幕"
|
_name_no_chinese_re = r".*版|.*字幕"
|
||||||
@@ -524,16 +524,7 @@ class MetaVideo(MetaBase):
|
|||||||
"""
|
"""
|
||||||
if not self.name:
|
if not self.name:
|
||||||
return
|
return
|
||||||
source_res = re.search(r"(%s)" % self._source_re, token, re.IGNORECASE)
|
if token.upper() == "DL" \
|
||||||
if source_res:
|
|
||||||
self._last_token_type = "source"
|
|
||||||
self._continue_flag = False
|
|
||||||
self._stop_name_flag = True
|
|
||||||
if not self._source:
|
|
||||||
self._source = source_res.group(1)
|
|
||||||
self._last_token = self._source.upper()
|
|
||||||
return
|
|
||||||
elif token.upper() == "DL" \
|
|
||||||
and self._last_token_type == "source" \
|
and self._last_token_type == "source" \
|
||||||
and self._last_token == "WEB":
|
and self._last_token == "WEB":
|
||||||
self._source = "WEB-DL"
|
self._source = "WEB-DL"
|
||||||
@@ -542,13 +533,37 @@ class MetaVideo(MetaBase):
|
|||||||
elif token.upper() == "RAY" \
|
elif token.upper() == "RAY" \
|
||||||
and self._last_token_type == "source" \
|
and self._last_token_type == "source" \
|
||||||
and self._last_token == "BLU":
|
and self._last_token == "BLU":
|
||||||
self._source = "BluRay"
|
# UHD BluRay组合
|
||||||
|
if self._source == "UHD":
|
||||||
|
self._source = "UHD BluRay"
|
||||||
|
else:
|
||||||
|
self._source = "BluRay"
|
||||||
self._continue_flag = False
|
self._continue_flag = False
|
||||||
return
|
return
|
||||||
elif token.upper() == "WEBDL":
|
elif token.upper() == "WEBDL":
|
||||||
self._source = "WEB-DL"
|
self._source = "WEB-DL"
|
||||||
self._continue_flag = False
|
self._continue_flag = False
|
||||||
return
|
return
|
||||||
|
# UHD REMUX组合
|
||||||
|
if token.upper() == "REMUX" \
|
||||||
|
and self._source == "BluRay":
|
||||||
|
self._source = "BluRay REMUX"
|
||||||
|
self._continue_flag = False
|
||||||
|
return
|
||||||
|
elif token.upper() == "BLURAY" \
|
||||||
|
and self._source == "UHD":
|
||||||
|
self._source = "UHD BluRay"
|
||||||
|
self._continue_flag = False
|
||||||
|
return
|
||||||
|
source_res = re.search(r"(%s)" % self._source_re, token, re.IGNORECASE)
|
||||||
|
if source_res:
|
||||||
|
self._last_token_type = "source"
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
if not self._source:
|
||||||
|
self._source = source_res.group(1)
|
||||||
|
self._last_token = self._source.upper()
|
||||||
|
return
|
||||||
effect_res = re.search(r"(%s)" % self._effect_re, token, re.IGNORECASE)
|
effect_res = re.search(r"(%s)" % self._effect_re, token, re.IGNORECASE)
|
||||||
if effect_res:
|
if effect_res:
|
||||||
self._last_token_type = "effect"
|
self._last_token_type = "effect"
|
||||||
|
|||||||
@@ -526,7 +526,8 @@ class PluginManager(metaclass=Singleton):
|
|||||||
"name": "服务名称",
|
"name": "服务名称",
|
||||||
"trigger": "触发器:cron、interval、date、CronTrigger.from_crontab()",
|
"trigger": "触发器:cron、interval、date、CronTrigger.from_crontab()",
|
||||||
"func": self.xxx,
|
"func": self.xxx,
|
||||||
"kwargs": {} # 定时器参数
|
"kwargs": {} # 定时器参数,
|
||||||
|
"func_kwargs": {} # 方法参数
|
||||||
}]
|
}]
|
||||||
"""
|
"""
|
||||||
ret_services = []
|
ret_services = []
|
||||||
|
|||||||
@@ -46,11 +46,13 @@ class Site(Base):
|
|||||||
# 流控间隔
|
# 流控间隔
|
||||||
limit_seconds = Column(Integer, default=0)
|
limit_seconds = Column(Integer, default=0)
|
||||||
# 超时时间
|
# 超时时间
|
||||||
timeout = Column(Integer, default=0)
|
timeout = Column(Integer, default=15)
|
||||||
# 是否启用
|
# 是否启用
|
||||||
is_active = Column(Boolean(), default=True)
|
is_active = Column(Boolean(), default=True)
|
||||||
# 创建时间
|
# 创建时间
|
||||||
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
# 下载器
|
||||||
|
downloader = Column(String)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@db_query
|
@db_query
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ class Subscribe(Base):
|
|||||||
username = Column(String)
|
username = Column(String)
|
||||||
# 订阅站点
|
# 订阅站点
|
||||||
sites = Column(JSON, default=list)
|
sites = Column(JSON, default=list)
|
||||||
|
# 下载器
|
||||||
|
downloader = Column(String)
|
||||||
# 是否洗版
|
# 是否洗版
|
||||||
best_version = Column(Integer, default=0)
|
best_version = Column(Integer, default=0)
|
||||||
# 当前优先级
|
# 当前优先级
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app import schemas
|
|||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
|
from app.utils.system import SystemUtils
|
||||||
|
|
||||||
|
|
||||||
class DirectoryHelper:
|
class DirectoryHelper:
|
||||||
@@ -48,16 +49,19 @@ class DirectoryHelper:
|
|||||||
"""
|
"""
|
||||||
return [d for d in self.get_library_dirs() if d.library_storage == "local"]
|
return [d for d in self.get_library_dirs() if d.library_storage == "local"]
|
||||||
|
|
||||||
def get_dir(self, media: MediaInfo, storage: str = "local",
|
def get_dir(self, media: MediaInfo, include_unsorted: bool = False,
|
||||||
src_path: Path = None, dest_path: Path = None, fileitem: schemas.FileItem = None
|
storage: str = None, src_path: Path = None,
|
||||||
|
target_storage: str = None, dest_path: Path = None
|
||||||
) -> Optional[schemas.TransferDirectoryConf]:
|
) -> Optional[schemas.TransferDirectoryConf]:
|
||||||
"""
|
"""
|
||||||
根据媒体信息获取下载目录、媒体库目录配置
|
根据媒体信息获取下载目录、媒体库目录配置
|
||||||
:param media: 媒体信息
|
:param media: 媒体信息
|
||||||
:param storage: 存储类型
|
:param include_unsorted: 包含不整理目录
|
||||||
|
:param storage: 源存储类型
|
||||||
|
:param target_storage: 目标存储类型
|
||||||
|
:param fileitem: 文件项,使用文件路径匹配
|
||||||
:param src_path: 源目录,有值时直接匹配
|
:param src_path: 源目录,有值时直接匹配
|
||||||
:param dest_path: 目标目录,有值时直接匹配
|
:param dest_path: 目标目录,有值时直接匹配
|
||||||
:param fileitem: 文件项,使用文件路径匹配
|
|
||||||
"""
|
"""
|
||||||
# 处理类型
|
# 处理类型
|
||||||
if not media:
|
if not media:
|
||||||
@@ -65,35 +69,43 @@ class DirectoryHelper:
|
|||||||
# 电影/电视剧
|
# 电影/电视剧
|
||||||
media_type = media.type.value
|
media_type = media.type.value
|
||||||
dirs = self.get_dirs()
|
dirs = self.get_dirs()
|
||||||
|
# 已匹配的目录
|
||||||
|
matched_dirs: List[schemas.TransferDirectoryConf] = []
|
||||||
# 按照配置顺序查找
|
# 按照配置顺序查找
|
||||||
for d in dirs:
|
for d in dirs:
|
||||||
# 没有启用整理的目录
|
# 没有启用整理的目录
|
||||||
if not d.monitor_type:
|
if not d.monitor_type and not include_unsorted:
|
||||||
continue
|
continue
|
||||||
# 存储类型不匹配
|
# 源存储类型不匹配
|
||||||
if storage and d.storage != storage:
|
if storage and d.storage != storage:
|
||||||
continue
|
continue
|
||||||
# 下载目录
|
# 目标存储类型不匹配
|
||||||
download_path = Path(d.download_path)
|
if target_storage and d.library_storage != target_storage:
|
||||||
# 媒体库目录
|
|
||||||
library_path = Path(d.library_path)
|
|
||||||
# 有源目录时,源目录不匹配下载目录
|
|
||||||
if src_path and not src_path.is_relative_to(download_path):
|
|
||||||
continue
|
continue
|
||||||
# 有文件项时,文件项不匹配下载目录
|
# 有源目录时,源目录不匹配下载目录
|
||||||
if fileitem and not Path(fileitem.path).is_relative_to(download_path):
|
if src_path and not src_path.is_relative_to(d.download_path):
|
||||||
continue
|
continue
|
||||||
# 有目标目录时,目标目录不匹配媒体库目录
|
# 有目标目录时,目标目录不匹配媒体库目录
|
||||||
if dest_path and not dest_path.is_relative_to(library_path):
|
if dest_path and dest_path != Path(d.library_path):
|
||||||
continue
|
continue
|
||||||
# 目录类型为全部的,符合条件
|
# 目录类型为全部的,符合条件
|
||||||
if not d.media_type:
|
if not d.media_type:
|
||||||
return d
|
matched_dirs.append(d)
|
||||||
|
continue
|
||||||
# 目录类型相等,目录类别为全部,符合条件
|
# 目录类型相等,目录类别为全部,符合条件
|
||||||
if d.media_type == media_type and not d.media_category:
|
if d.media_type == media_type and not d.media_category:
|
||||||
return d
|
matched_dirs.append(d)
|
||||||
|
continue
|
||||||
# 目录类型相等,目录类别相等,符合条件
|
# 目录类型相等,目录类别相等,符合条件
|
||||||
if d.media_type == media_type and d.media_category == media.category:
|
if d.media_type == media_type and d.media_category == media.category:
|
||||||
return d
|
matched_dirs.append(d)
|
||||||
|
continue
|
||||||
|
if matched_dirs:
|
||||||
|
if src_path:
|
||||||
|
# 优先源目录同盘
|
||||||
|
for matched_dir in matched_dirs:
|
||||||
|
matched_path = Path(matched_dir.download_path)
|
||||||
|
if SystemUtils.is_same_disk(matched_path, src_path):
|
||||||
|
return matched_dir
|
||||||
|
return matched_dirs[0]
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -287,10 +287,10 @@ class TorrentHelper(metaclass=Singleton):
|
|||||||
if not file:
|
if not file:
|
||||||
continue
|
continue
|
||||||
file_path = Path(file)
|
file_path = Path(file)
|
||||||
if file_path.suffix not in settings.RMT_MEDIAEXT:
|
if not file_path.suffix or file_path.suffix.lower() not in settings.RMT_MEDIAEXT:
|
||||||
continue
|
continue
|
||||||
# 只使用文件名识别
|
# 只使用文件名识别
|
||||||
meta = MetaInfo(file_path.stem)
|
meta = MetaInfo(file_path.name)
|
||||||
if not meta.begin_episode:
|
if not meta.begin_episode:
|
||||||
continue
|
continue
|
||||||
episodes = list(set(episodes).union(set(meta.episode_list)))
|
episodes = list(set(episodes).union(set(meta.episode_list)))
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import lru_cache
|
|
||||||
from random import choice
|
from random import choice
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from cachetools import TTLCache, cached
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
@@ -160,12 +160,12 @@ class DoubanApi(metaclass=Singleton):
|
|||||||
self._session = requests.Session()
|
self._session = requests.Session()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __sign(cls, url: str, ts: int, method='GET') -> str:
|
def __sign(cls, url: str, ts: str, method='GET') -> str:
|
||||||
"""
|
"""
|
||||||
签名
|
签名
|
||||||
"""
|
"""
|
||||||
url_path = parse.urlparse(url).path
|
url_path = parse.urlparse(url).path
|
||||||
raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), str(ts)])
|
raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), ts])
|
||||||
return base64.b64encode(
|
return base64.b64encode(
|
||||||
hmac.new(
|
hmac.new(
|
||||||
cls._api_secret_key.encode(),
|
cls._api_secret_key.encode(),
|
||||||
@@ -174,7 +174,7 @@ class DoubanApi(metaclass=Singleton):
|
|||||||
).digest()
|
).digest()
|
||||||
).decode()
|
).decode()
|
||||||
|
|
||||||
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
|
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
|
||||||
def __invoke(self, url: str, **kwargs) -> dict:
|
def __invoke(self, url: str, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
GET请求
|
GET请求
|
||||||
@@ -203,7 +203,7 @@ class DoubanApi(metaclass=Singleton):
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
return resp.json() if resp else {}
|
return resp.json() if resp else {}
|
||||||
|
|
||||||
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
|
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
|
||||||
def __post(self, url: str, **kwargs) -> dict:
|
def __post(self, url: str, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
POST请求
|
POST请求
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from app.schemas.types import MediaType
|
|||||||
lock = RLock()
|
lock = RLock()
|
||||||
|
|
||||||
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
|
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
|
||||||
EXPIRE_TIMESTAMP = settings.CACHE_CONF.get('meta')
|
EXPIRE_TIMESTAMP = settings.CACHE_CONF["meta"]
|
||||||
|
|
||||||
|
|
||||||
class DoubanCache(metaclass=Singleton):
|
class DoubanCache(metaclass=Singleton):
|
||||||
@@ -77,7 +77,7 @@ class DoubanCache(metaclass=Singleton):
|
|||||||
@return: 被删除的缓存内容
|
@return: 被删除的缓存内容
|
||||||
"""
|
"""
|
||||||
with lock:
|
with lock:
|
||||||
return self._meta_data.pop(key, None)
|
return self._meta_data.pop(key, {})
|
||||||
|
|
||||||
def delete_by_doubanid(self, doubanid: str) -> None:
|
def delete_by_doubanid(self, doubanid: str) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import re
|
import re
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Optional, Tuple, Union
|
from typing import Optional, Tuple, Union
|
||||||
|
|
||||||
|
from cachetools import TTLCache, cached
|
||||||
|
|
||||||
from app.core.context import MediaInfo, settings
|
from app.core.context import MediaInfo, settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.modules import _ModuleBase
|
from app.modules import _ModuleBase
|
||||||
from app.utils.http import RequestUtils
|
|
||||||
from app.schemas.types import MediaType, ModuleType
|
from app.schemas.types import MediaType, ModuleType
|
||||||
|
from app.utils.http import RequestUtils
|
||||||
|
|
||||||
|
|
||||||
class FanartModule(_ModuleBase):
|
class FanartModule(_ModuleBase):
|
||||||
@@ -404,7 +405,7 @@ class FanartModule(_ModuleBase):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@lru_cache(maxsize=settings.CACHE_CONF.get('fanart'))
|
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["fanart"], ttl=settings.CACHE_CONF["meta"]))
|
||||||
def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]:
|
def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]:
|
||||||
if media_type == MediaType.MOVIE:
|
if media_type == MediaType.MOVIE:
|
||||||
image_url = cls._movie_url % queryid
|
image_url = cls._movie_url % queryid
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import copy
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
@@ -8,6 +7,7 @@ from jinja2 import Template
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
|
from app.core.event import eventmanager
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||||
from app.helper.directory import DirectoryHelper
|
from app.helper.directory import DirectoryHelper
|
||||||
@@ -17,7 +17,8 @@ from app.log import logger
|
|||||||
from app.modules import _ModuleBase
|
from app.modules import _ModuleBase
|
||||||
from app.modules.filemanager.storages import StorageBase
|
from app.modules.filemanager.storages import StorageBase
|
||||||
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage
|
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage
|
||||||
from app.schemas.types import MediaType, ModuleType
|
from app.schemas.event import TransferRenameEventData
|
||||||
|
from app.schemas.types import MediaType, ModuleType, ChainEventType
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
|
|
||||||
lock = Lock()
|
lock = Lock()
|
||||||
@@ -131,8 +132,6 @@ class FileManagerModule(_ModuleBase):
|
|||||||
)
|
)
|
||||||
return str(path)
|
return str(path)
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
def save_config(self, storage: str, conf: Dict) -> None:
|
def save_config(self, storage: str, conf: Dict) -> None:
|
||||||
"""
|
"""
|
||||||
保存存储配置
|
保存存储配置
|
||||||
@@ -143,7 +142,7 @@ class FileManagerModule(_ModuleBase):
|
|||||||
return
|
return
|
||||||
storage_oper.set_config(conf)
|
storage_oper.set_config(conf)
|
||||||
|
|
||||||
def generate_qrcode(self, storage: str) -> Optional[Dict[str, str]]:
|
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
|
||||||
"""
|
"""
|
||||||
生成二维码
|
生成二维码
|
||||||
"""
|
"""
|
||||||
@@ -219,7 +218,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
and f".{t.extension.lower()}" in extensions):
|
and f".{t.extension.lower()}" in extensions):
|
||||||
return True
|
return True
|
||||||
elif t.type == "dir":
|
elif t.type == "dir":
|
||||||
return __any_file(t)
|
if __any_file(t):
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 返回结果
|
# 返回结果
|
||||||
@@ -268,7 +268,7 @@ class FileManagerModule(_ModuleBase):
|
|||||||
return None
|
return None
|
||||||
return storage_oper.download(fileitem, path=path)
|
return storage_oper.download(fileitem, path=path)
|
||||||
|
|
||||||
def upload_file(self, fileitem: FileItem, path: Path) -> Optional[FileItem]:
|
def upload_file(self, fileitem: FileItem, path: Path, new_name: str = None) -> Optional[FileItem]:
|
||||||
"""
|
"""
|
||||||
上传文件
|
上传文件
|
||||||
"""
|
"""
|
||||||
@@ -276,7 +276,7 @@ class FileManagerModule(_ModuleBase):
|
|||||||
if not storage_oper:
|
if not storage_oper:
|
||||||
logger.error(f"不支持 {fileitem.storage} 的上传处理")
|
logger.error(f"不支持 {fileitem.storage} 的上传处理")
|
||||||
return None
|
return None
|
||||||
return storage_oper.upload(fileitem, path)
|
return storage_oper.upload(fileitem, path, new_name)
|
||||||
|
|
||||||
def get_file_item(self, storage: str, path: Path) -> Optional[FileItem]:
|
def get_file_item(self, storage: str, path: Path) -> Optional[FileItem]:
|
||||||
"""
|
"""
|
||||||
@@ -322,6 +322,7 @@ class FileManagerModule(_ModuleBase):
|
|||||||
target_directory: TransferDirectoryConf = None,
|
target_directory: TransferDirectoryConf = None,
|
||||||
target_storage: str = None, target_path: Path = None,
|
target_storage: str = None, target_path: Path = None,
|
||||||
transfer_type: str = None, scrape: bool = None,
|
transfer_type: str = None, scrape: bool = None,
|
||||||
|
library_type_folder: bool = None, library_category_folder: bool = None,
|
||||||
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
|
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
|
||||||
"""
|
"""
|
||||||
文件整理
|
文件整理
|
||||||
@@ -333,6 +334,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
:param target_path: 目标路径
|
:param target_path: 目标路径
|
||||||
:param transfer_type: 转移模式
|
:param transfer_type: 转移模式
|
||||||
:param scrape: 是否刮削元数据
|
:param scrape: 是否刮削元数据
|
||||||
|
:param library_type_folder: 是否按媒体类型创建目录
|
||||||
|
:param library_category_folder: 是否按媒体类别创建目录
|
||||||
:param episodes_info: 当前季的全部集信息
|
:param episodes_info: 当前季的全部集信息
|
||||||
:return: {path, target_path, message}
|
:return: {path, target_path, message}
|
||||||
"""
|
"""
|
||||||
@@ -349,37 +352,36 @@ class FileManagerModule(_ModuleBase):
|
|||||||
message=f"{target_path} 不是有效目录")
|
message=f"{target_path} 不是有效目录")
|
||||||
# 获取目标路径
|
# 获取目标路径
|
||||||
if target_directory:
|
if target_directory:
|
||||||
# 拼装媒体库一、二级子目录
|
|
||||||
target_path = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_directory)
|
|
||||||
# 目标存储类型
|
|
||||||
if not target_storage:
|
|
||||||
target_storage = target_directory.library_storage
|
|
||||||
# 整理方式
|
# 整理方式
|
||||||
if not transfer_type:
|
if not transfer_type:
|
||||||
transfer_type = target_directory.transfer_type
|
transfer_type = target_directory.transfer_type
|
||||||
if not transfer_type:
|
|
||||||
logger.error(f"{target_directory.name} 未设置整理方式")
|
|
||||||
return TransferInfo(success=False,
|
|
||||||
fileitem=fileitem,
|
|
||||||
message=f"{target_directory.name} 未设置整理方式")
|
|
||||||
# 是否需要刮削
|
|
||||||
if scrape is None:
|
|
||||||
need_scrape = target_directory.scraping
|
|
||||||
else:
|
|
||||||
need_scrape = scrape
|
|
||||||
# 是否需要重命名
|
# 是否需要重命名
|
||||||
need_rename = target_directory.renaming
|
need_rename = target_directory.renaming
|
||||||
# 是否需要通知
|
# 是否需要通知
|
||||||
need_notify = target_directory.notify
|
need_notify = target_directory.notify
|
||||||
# 覆盖模式
|
# 覆盖模式
|
||||||
overwrite_mode = target_directory.overwrite_mode
|
overwrite_mode = target_directory.overwrite_mode
|
||||||
|
# 是否需要刮削
|
||||||
|
if scrape is None:
|
||||||
|
need_scrape = target_directory.scraping
|
||||||
|
else:
|
||||||
|
need_scrape = scrape
|
||||||
|
# 目标存储类型
|
||||||
|
if not target_storage:
|
||||||
|
target_storage = target_directory.library_storage
|
||||||
|
# 拼装媒体库一、二级子目录
|
||||||
|
target_path = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_directory,
|
||||||
|
need_type_folder=library_type_folder,
|
||||||
|
need_category_folder=library_category_folder)
|
||||||
elif target_path:
|
elif target_path:
|
||||||
# 手动整理的场景,有自定义目标路径
|
|
||||||
need_scrape = scrape or False
|
need_scrape = scrape or False
|
||||||
need_rename = True
|
need_rename = True
|
||||||
need_notify = False
|
need_notify = False
|
||||||
overwrite_mode = "never"
|
overwrite_mode = "never"
|
||||||
logger.warn(f"{target_path} 为自定义路径, 通知将不会发送")
|
# 手动整理的场景,有自定义目标路径
|
||||||
|
target_path = self.__get_dest_path(mediainfo=mediainfo, target_path=target_path,
|
||||||
|
need_type_folder=library_type_folder,
|
||||||
|
need_category_folder=library_category_folder)
|
||||||
else:
|
else:
|
||||||
# 未找到有效的媒体库目录
|
# 未找到有效的媒体库目录
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -387,9 +389,14 @@ class FileManagerModule(_ModuleBase):
|
|||||||
return TransferInfo(success=False,
|
return TransferInfo(success=False,
|
||||||
fileitem=fileitem,
|
fileitem=fileitem,
|
||||||
message="未找到有效的媒体库目录")
|
message="未找到有效的媒体库目录")
|
||||||
|
# 整理方式
|
||||||
logger.info(f"获取整理目标路径:【{target_storage}】{target_path}")
|
if not transfer_type:
|
||||||
|
logger.error(f"{target_directory.name} 未设置整理方式")
|
||||||
|
return TransferInfo(success=False,
|
||||||
|
fileitem=fileitem,
|
||||||
|
message=f"{target_directory.name} 未设置整理方式")
|
||||||
# 整理
|
# 整理
|
||||||
|
logger.info(f"获取整理目标路径:【{target_storage}】{target_path}")
|
||||||
return self.transfer_media(fileitem=fileitem,
|
return self.transfer_media(fileitem=fileitem,
|
||||||
in_meta=meta,
|
in_meta=meta,
|
||||||
mediainfo=mediainfo,
|
mediainfo=mediainfo,
|
||||||
@@ -463,9 +470,9 @@ class FileManagerModule(_ModuleBase):
|
|||||||
target_file.parent.mkdir(parents=True)
|
target_file.parent.mkdir(parents=True)
|
||||||
# 本地到本地
|
# 本地到本地
|
||||||
if transfer_type == "copy":
|
if transfer_type == "copy":
|
||||||
state = source_oper.copy(fileitem, target_file)
|
state = source_oper.copy(fileitem, target_file.parent, target_file.name)
|
||||||
elif transfer_type == "move":
|
elif transfer_type == "move":
|
||||||
state = source_oper.move(fileitem, target_file)
|
state = source_oper.move(fileitem, target_file.parent, target_file.name)
|
||||||
elif transfer_type == "link":
|
elif transfer_type == "link":
|
||||||
state = source_oper.link(fileitem, target_file)
|
state = source_oper.link(fileitem, target_file)
|
||||||
elif transfer_type == "softlink":
|
elif transfer_type == "softlink":
|
||||||
@@ -487,38 +494,28 @@ class FileManagerModule(_ModuleBase):
|
|||||||
target_fileitem = target_oper.get_folder(target_file.parent)
|
target_fileitem = target_oper.get_folder(target_file.parent)
|
||||||
if target_fileitem:
|
if target_fileitem:
|
||||||
# 上传文件
|
# 上传文件
|
||||||
new_item = target_oper.upload(target_fileitem, filepath)
|
new_item = target_oper.upload(target_fileitem, filepath, target_file.name)
|
||||||
if new_item:
|
if new_item:
|
||||||
# 重命名为目标文件名
|
|
||||||
if new_item.name != target_file.name:
|
|
||||||
if target_oper.rename(new_item, target_file.name):
|
|
||||||
new_item.name = target_file.name
|
|
||||||
new_item.path = str(Path(new_item.path).parent / target_file.name)
|
|
||||||
return new_item, ""
|
return new_item, ""
|
||||||
else:
|
else:
|
||||||
return None, f"{fileitem.path} 上传 {target_storage} 失败"
|
return None, f"{fileitem.path} 上传 {target_storage} 失败"
|
||||||
else:
|
else:
|
||||||
return None, f"{target_file.parent} {target_storage} 目录获取失败"
|
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||||
elif transfer_type == "move":
|
elif transfer_type == "move":
|
||||||
# 移动
|
# 移动
|
||||||
# 根据目的路径获取文件夹
|
# 根据目的路径获取文件夹
|
||||||
target_fileitem = target_oper.get_folder(target_file.parent)
|
target_fileitem = target_oper.get_folder(target_file.parent)
|
||||||
if target_fileitem:
|
if target_fileitem:
|
||||||
# 上传文件
|
# 上传文件
|
||||||
new_item = target_oper.upload(target_fileitem, filepath)
|
new_item = target_oper.upload(target_fileitem, filepath, target_file.name)
|
||||||
if new_item:
|
if new_item:
|
||||||
# 重命名为目标文件名
|
|
||||||
if new_item.name != target_file.name:
|
|
||||||
if target_oper.rename(new_item, target_file.name):
|
|
||||||
new_item.name = target_file.name
|
|
||||||
new_item.path = str(Path(new_item.path).parent / target_file.name)
|
|
||||||
# 删除源文件
|
# 删除源文件
|
||||||
source_oper.delete(fileitem)
|
source_oper.delete(fileitem)
|
||||||
return new_item, ""
|
return new_item, ""
|
||||||
else:
|
else:
|
||||||
return None, f"{fileitem.path} 上传 {target_storage} 失败"
|
return None, f"{fileitem.path} 上传 {target_storage} 失败"
|
||||||
else:
|
else:
|
||||||
return None, f"{target_file.parent} {target_storage} 目录获取失败"
|
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||||
elif fileitem.storage != "local" and target_storage == "local":
|
elif fileitem.storage != "local" and target_storage == "local":
|
||||||
# 网盘到本地
|
# 网盘到本地
|
||||||
if target_file.exists():
|
if target_file.exists():
|
||||||
@@ -542,25 +539,28 @@ class FileManagerModule(_ModuleBase):
|
|||||||
return None, f"{fileitem.path} {fileitem.storage} 下载失败"
|
return None, f"{fileitem.path} {fileitem.storage} 下载失败"
|
||||||
elif fileitem.storage == target_storage:
|
elif fileitem.storage == target_storage:
|
||||||
# 同一网盘
|
# 同一网盘
|
||||||
# 根据目的路径获取文件夹
|
if transfer_type == "copy":
|
||||||
target_diritem = target_oper.get_folder(target_file.parent)
|
# 复制文件到新目录
|
||||||
if target_diritem:
|
target_fileitem = target_oper.get_folder(target_file.parent)
|
||||||
# 重命名文件
|
if target_fileitem:
|
||||||
if target_oper.rename(fileitem, target_file.name):
|
if source_oper.move(fileitem, Path(target_fileitem.path), target_file.name):
|
||||||
# 移动文件到新目录
|
return target_oper.get_item(target_file), ""
|
||||||
if source_oper.move(fileitem, target_diritem):
|
|
||||||
ret_fileitem = copy.deepcopy(fileitem)
|
|
||||||
ret_fileitem.path = target_diritem.path + "/" + target_file.name
|
|
||||||
ret_fileitem.name = target_file.name
|
|
||||||
ret_fileitem.basename = target_file.stem
|
|
||||||
ret_fileitem.parent_fileid = target_diritem.fileid
|
|
||||||
return ret_fileitem, ""
|
|
||||||
else:
|
else:
|
||||||
return None, f"{fileitem.path} {target_storage} 移动文件失败"
|
return None, f"【{target_storage}】{fileitem.path} 复制文件失败"
|
||||||
else:
|
else:
|
||||||
return None, f"{fileitem.path} {target_storage} 重命名文件失败"
|
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||||
|
elif transfer_type == "move":
|
||||||
|
# 移动文件到新目录
|
||||||
|
target_fileitem = target_oper.get_folder(target_file.parent)
|
||||||
|
if target_fileitem:
|
||||||
|
if source_oper.move(fileitem, Path(target_fileitem.path), target_file.name):
|
||||||
|
return target_oper.get_item(target_file), ""
|
||||||
|
else:
|
||||||
|
return None, f"【{target_storage}】{fileitem.path} 移动文件失败"
|
||||||
|
else:
|
||||||
|
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
|
||||||
else:
|
else:
|
||||||
return None, f"{target_file.parent} {target_storage} 目录获取失败"
|
return None, f"不支持的整理方式:{transfer_type}"
|
||||||
|
|
||||||
return None, "未知错误"
|
return None, "未知错误"
|
||||||
|
|
||||||
@@ -825,7 +825,8 @@ class FileManagerModule(_ModuleBase):
|
|||||||
else:
|
else:
|
||||||
logger.info(f"正在删除已存在的文件:{target_file}")
|
logger.info(f"正在删除已存在的文件:{target_file}")
|
||||||
target_file.unlink()
|
target_file.unlink()
|
||||||
logger.info(f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file}")
|
logger.info(f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file},"
|
||||||
|
f"操作类型:{transfer_type}")
|
||||||
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
|
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
target_file=target_file,
|
target_file=target_file,
|
||||||
@@ -841,26 +842,43 @@ class FileManagerModule(_ModuleBase):
|
|||||||
return None, errmsg
|
return None, errmsg
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_dest_dir(mediainfo: MediaInfo, target_dir: TransferDirectoryConf) -> Path:
|
def __get_dest_path(mediainfo: MediaInfo, target_path: Path,
|
||||||
|
need_type_folder: bool = False, need_category_folder: bool = False):
|
||||||
|
"""
|
||||||
|
获取目标路径
|
||||||
|
"""
|
||||||
|
if need_type_folder:
|
||||||
|
target_path = target_path / mediainfo.type.value
|
||||||
|
if need_category_folder and mediainfo.category:
|
||||||
|
target_path = target_path / mediainfo.category
|
||||||
|
return target_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_dest_dir(mediainfo: MediaInfo, target_dir: TransferDirectoryConf,
|
||||||
|
need_type_folder: bool = None, need_category_folder: bool = None) -> Path:
|
||||||
"""
|
"""
|
||||||
根据设置并装媒体库目录
|
根据设置并装媒体库目录
|
||||||
:param mediainfo: 媒体信息
|
:param mediainfo: 媒体信息
|
||||||
:target_dir: 媒体库根目录
|
:target_dir: 媒体库根目录
|
||||||
:typename_dir: 是否加上类型目录
|
:need_type_folder: 是否需要按媒体类型创建目录
|
||||||
|
:need_category_folder: 是否需要按媒体类别创建目录
|
||||||
"""
|
"""
|
||||||
if not target_dir.media_type and target_dir.library_type_folder:
|
if need_type_folder is None:
|
||||||
|
need_type_folder = target_dir.library_type_folder
|
||||||
|
if need_category_folder is None:
|
||||||
|
need_category_folder = target_dir.library_category_folder
|
||||||
|
if not target_dir.media_type and need_type_folder:
|
||||||
# 一级自动分类
|
# 一级自动分类
|
||||||
library_dir = Path(target_dir.library_path) / mediainfo.type.value
|
library_dir = Path(target_dir.library_path) / mediainfo.type.value
|
||||||
elif target_dir.media_type and target_dir.library_type_folder:
|
elif target_dir.media_type and need_type_folder:
|
||||||
# 一级手动分类
|
# 一级手动分类
|
||||||
library_dir = Path(target_dir.library_path) / target_dir.media_type
|
library_dir = Path(target_dir.library_path) / target_dir.media_type
|
||||||
else:
|
else:
|
||||||
library_dir = Path(target_dir.library_path)
|
library_dir = Path(target_dir.library_path)
|
||||||
|
if not target_dir.media_category and need_category_folder and mediainfo.category:
|
||||||
if not target_dir.media_category and target_dir.library_category_folder and mediainfo.category:
|
|
||||||
# 二级自动分类
|
# 二级自动分类
|
||||||
library_dir = library_dir / mediainfo.category
|
library_dir = library_dir / mediainfo.category
|
||||||
elif target_dir.media_category and target_dir.library_category_folder:
|
elif target_dir.media_category and need_category_folder:
|
||||||
# 二级手动分类
|
# 二级手动分类
|
||||||
library_dir = library_dir / target_dir.media_category
|
library_dir = library_dir / target_dir.media_category
|
||||||
|
|
||||||
@@ -899,6 +917,18 @@ class FileManagerModule(_ModuleBase):
|
|||||||
rename_format = settings.TV_RENAME_FORMAT \
|
rename_format = settings.TV_RENAME_FORMAT \
|
||||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||||
|
|
||||||
|
# 计算重命名中的文件夹层数
|
||||||
|
rename_format_level = len(rename_format.split("/")) - 1
|
||||||
|
|
||||||
|
if rename_format_level < 1:
|
||||||
|
# 重命名格式不合法
|
||||||
|
logger.error(f"重命名格式不合法:{rename_format}")
|
||||||
|
return TransferInfo(success=False,
|
||||||
|
message=f"重命名格式不合法",
|
||||||
|
fileitem=fileitem,
|
||||||
|
transfer_type=transfer_type,
|
||||||
|
need_notify=need_notify)
|
||||||
|
|
||||||
# 判断是否为文件夹
|
# 判断是否为文件夹
|
||||||
if fileitem.type == "dir":
|
if fileitem.type == "dir":
|
||||||
# 整理整个目录,一般为蓝光原盘
|
# 整理整个目录,一般为蓝光原盘
|
||||||
@@ -979,9 +1009,15 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 目的操作对象
|
# 目的操作对象
|
||||||
target_oper: StorageBase = self.__get_storage_oper(target_storage)
|
target_oper: StorageBase = self.__get_storage_oper(target_storage)
|
||||||
# 目标目录
|
# 目标目录
|
||||||
target_diritem = target_oper.get_folder(
|
target_diritem = target_oper.get_folder(new_file.parents[rename_format_level - 1])
|
||||||
new_file.parent) if mediainfo.type == MediaType.MOVIE else target_oper.get_folder(
|
if not target_diritem:
|
||||||
new_file.parent.parent)
|
logger.error(f"目标目录 {new_file.parents[rename_format_level - 1]} 获取失败")
|
||||||
|
return TransferInfo(success=False,
|
||||||
|
message=f"目标目录 {new_file.parents[rename_format_level - 1]} 获取失败",
|
||||||
|
fileitem=fileitem,
|
||||||
|
fail_list=[fileitem.path],
|
||||||
|
transfer_type=transfer_type,
|
||||||
|
need_notify=need_notify)
|
||||||
# 目标文件
|
# 目标文件
|
||||||
target_item = target_oper.get_item(new_file)
|
target_item = target_oper.get_item(new_file)
|
||||||
if target_item:
|
if target_item:
|
||||||
@@ -1090,7 +1126,14 @@ class FileManagerModule(_ModuleBase):
|
|||||||
if episode.episode_number == meta.begin_episode:
|
if episode.episode_number == meta.begin_episode:
|
||||||
episode_title = episode.name
|
episode_title = episode.name
|
||||||
break
|
break
|
||||||
|
# 获取集播出日期
|
||||||
|
episode_date = None
|
||||||
|
if meta.begin_episode and episodes_info:
|
||||||
|
for episode in episodes_info:
|
||||||
|
if episode.episode_number == meta.begin_episode:
|
||||||
|
episode_date = episode.air_date
|
||||||
|
break
|
||||||
|
|
||||||
return {
|
return {
|
||||||
# 标题
|
# 标题
|
||||||
"title": __convert_invalid_characters(mediainfo.title),
|
"title": __convert_invalid_characters(mediainfo.title),
|
||||||
@@ -1140,21 +1183,51 @@ class FileManagerModule(_ModuleBase):
|
|||||||
"part": meta.part,
|
"part": meta.part,
|
||||||
# 剧集标题
|
# 剧集标题
|
||||||
"episode_title": __convert_invalid_characters(episode_title),
|
"episode_title": __convert_invalid_characters(episode_title),
|
||||||
|
# 剧集日期根据episodes_info值获取
|
||||||
|
"episode_date": episode_date,
|
||||||
# 文件后缀
|
# 文件后缀
|
||||||
"fileExt": file_ext,
|
"fileExt": file_ext,
|
||||||
# 自定义占位符
|
# 自定义占位符
|
||||||
"customization": meta.customization
|
"customization": meta.customization,
|
||||||
|
# 文件元数据
|
||||||
|
"__meta__": meta,
|
||||||
|
# 识别的媒体信息
|
||||||
|
"__mediainfo__": mediainfo,
|
||||||
|
# 当前季的全部集信息
|
||||||
|
"__episodes_info__": episodes_info,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path:
|
def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path:
|
||||||
"""
|
"""
|
||||||
生成重命名后的完整路径
|
生成重命名后的完整路径,支持智能重命名事件
|
||||||
|
:param template_string: Jinja2 模板字符串
|
||||||
|
:param rename_dict: 渲染上下文,用于替换模板中的变量
|
||||||
|
:param path: 可选的基础路径,如果提供,将在其基础上拼接生成的路径
|
||||||
|
:return: 生成的完整路径
|
||||||
"""
|
"""
|
||||||
# 创建jinja2模板对象
|
# 创建jinja2模板对象
|
||||||
template = Template(template_string)
|
template = Template(template_string)
|
||||||
# 渲染生成的字符串
|
# 渲染生成的字符串
|
||||||
render_str = template.render(rename_dict)
|
render_str = template.render(rename_dict)
|
||||||
|
|
||||||
|
logger.debug(f"Initial render string: {render_str}")
|
||||||
|
# 发送智能重命名事件
|
||||||
|
event_data = TransferRenameEventData(
|
||||||
|
template_string=template_string,
|
||||||
|
rename_dict=rename_dict,
|
||||||
|
render_str=render_str,
|
||||||
|
path=path
|
||||||
|
)
|
||||||
|
event = eventmanager.send_event(ChainEventType.TransferRename, event_data)
|
||||||
|
# 检查事件返回的结果
|
||||||
|
if event and event.event_data:
|
||||||
|
event_data: TransferRenameEventData = event.event_data
|
||||||
|
if event_data.updated and event_data.updated_str:
|
||||||
|
logger.debug(f"Render string updated by event: "
|
||||||
|
f"{render_str} -> {event_data.updated_str} (source: {event_data.source})")
|
||||||
|
render_str = event_data.updated_str
|
||||||
|
|
||||||
# 目的路径
|
# 目的路径
|
||||||
if path:
|
if path:
|
||||||
return path / render_str
|
return path / render_str
|
||||||
@@ -1180,17 +1253,19 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 重命名格式
|
# 重命名格式
|
||||||
rename_format = settings.TV_RENAME_FORMAT \
|
rename_format = settings.TV_RENAME_FORMAT \
|
||||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||||
# 获取相对路径(重命名路径)
|
# 计算重命名中的文件夹层数
|
||||||
rel_path = self.get_rename_path(
|
rename_format_level = len(rename_format.split("/")) - 1
|
||||||
|
if rename_format_level < 1:
|
||||||
|
continue
|
||||||
|
# 获取路径(重命名路径)
|
||||||
|
target_path = self.get_rename_path(
|
||||||
|
path=dir_path,
|
||||||
template_string=rename_format,
|
template_string=rename_format,
|
||||||
rename_dict=self.__get_naming_dict(meta=MetaInfo(mediainfo.title),
|
rename_dict=self.__get_naming_dict(meta=MetaInfo(mediainfo.title),
|
||||||
mediainfo=mediainfo)
|
mediainfo=mediainfo)
|
||||||
)
|
)
|
||||||
# 取相对路径的第1层目录
|
# 取相对路径的第1层目录
|
||||||
if rel_path.parts:
|
media_path = target_path.parents[rename_format_level - 1]
|
||||||
media_path = dir_path / rel_path.parts[0]
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
# 检索媒体文件
|
# 检索媒体文件
|
||||||
fileitem = storage_oper.get_item(media_path)
|
fileitem = storage_oper.get_item(media_path)
|
||||||
if not fileitem:
|
if not fileitem:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List, Union, Dict
|
from typing import Optional, List, Union, Dict, Tuple
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.helper.storage import StorageHelper
|
from app.helper.storage import StorageHelper
|
||||||
@@ -16,7 +16,14 @@ class StorageBase(metaclass=ABCMeta):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.storagehelper = StorageHelper()
|
self.storagehelper = StorageHelper()
|
||||||
|
|
||||||
def generate_qrcode(self, *args, **kwargs) -> Optional[Dict[str, str]]:
|
@abstractmethod
|
||||||
|
def init_storage(self):
|
||||||
|
"""
|
||||||
|
初始化
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def generate_qrcode(self, *args, **kwargs) -> Optional[Tuple[dict, str]]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def check_login(self, *args, **kwargs) -> Optional[Dict[str, str]]:
|
def check_login(self, *args, **kwargs) -> Optional[Dict[str, str]]:
|
||||||
@@ -40,6 +47,7 @@ class StorageBase(metaclass=ABCMeta):
|
|||||||
设置配置
|
设置配置
|
||||||
"""
|
"""
|
||||||
self.storagehelper.set_storage(self.schema.value, conf)
|
self.storagehelper.set_storage(self.schema.value, conf)
|
||||||
|
self.init_storage()
|
||||||
|
|
||||||
def support_transtype(self) -> dict:
|
def support_transtype(self) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -71,6 +79,8 @@ class StorageBase(metaclass=ABCMeta):
|
|||||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
创建目录
|
创建目录
|
||||||
|
:param fileitem: 父目录
|
||||||
|
:param name: 目录名
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -114,16 +124,16 @@ class StorageBase(metaclass=ABCMeta):
|
|||||||
下载文件,保存到本地,返回本地临时文件地址
|
下载文件,保存到本地,返回本地临时文件地址
|
||||||
:param fileitem: 文件项
|
:param fileitem: 文件项
|
||||||
:param path: 文件保存路径
|
:param path: 文件保存路径
|
||||||
|
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def upload(self, fileitem: schemas.FileItem, path: Path) -> Optional[schemas.FileItem]:
|
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: str = None) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
上传文件
|
上传文件
|
||||||
:param fileitem: 上传目录项
|
:param fileitem: 上传目录项
|
||||||
:param path: 本地文件路径
|
:param path: 本地文件路径
|
||||||
|
:param new_name: 上传后文件名
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -135,16 +145,22 @@ class StorageBase(metaclass=ABCMeta):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def copy(self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]) -> bool:
|
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
复制文件
|
复制文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def move(self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]) -> bool:
|
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
移动文件
|
移动文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ from app.schemas.types import StorageSchema
|
|||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from aligo import Aligo, BaseFile
|
from aligo import Aligo, BaseFile
|
||||||
|
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
|
|
||||||
class AliPan(StorageBase):
|
class AliPan(StorageBase, metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
阿里云相关操作
|
阿里云相关操作
|
||||||
"""
|
"""
|
||||||
@@ -54,17 +55,27 @@ class AliPan(StorageBase):
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.debug('未发现 aria2c')
|
logger.debug('未发现 aria2c')
|
||||||
self._has_aria2c = False
|
self._has_aria2c = False
|
||||||
|
self.init_storage()
|
||||||
|
|
||||||
self.__init_aligo()
|
def init_storage(self):
|
||||||
|
|
||||||
def __init_aligo(self):
|
|
||||||
"""
|
"""
|
||||||
初始化 aligo
|
初始化 aligo
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def show_qrcode(qr_link: str):
|
||||||
|
"""
|
||||||
|
显示二维码
|
||||||
|
"""
|
||||||
|
logger.info(f"请用阿里云盘 App 扫码登录:{qr_link}")
|
||||||
|
|
||||||
refresh_token = self.__auth_params.get("refreshToken")
|
refresh_token = self.__auth_params.get("refreshToken")
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
self.aligo = Aligo(refresh_token=refresh_token, use_aria2=self._has_aria2c,
|
try:
|
||||||
name="MoviePilot V2", level=logging.ERROR)
|
self.aligo = Aligo(refresh_token=refresh_token, show=show_qrcode, use_aria2=self._has_aria2c,
|
||||||
|
name="MoviePilot V2", level=logging.ERROR, re_login=False)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"初始化阿里云盘失败:{str(err)}")
|
||||||
|
self.__clear_params()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __auth_params(self):
|
def __auth_params(self):
|
||||||
@@ -160,7 +171,7 @@ class AliPan(StorageBase):
|
|||||||
})
|
})
|
||||||
self.__update_params(data)
|
self.__update_params(data)
|
||||||
self.__update_drives()
|
self.__update_drives()
|
||||||
self.__init_aligo()
|
self.init_storage()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {}, f"bizExt 解码失败:{str(e)}"
|
return {}, f"bizExt 解码失败:{str(e)}"
|
||||||
return data, ""
|
return data, ""
|
||||||
@@ -180,12 +191,16 @@ class AliPan(StorageBase):
|
|||||||
"""
|
"""
|
||||||
获取用户信息(drive_id等)
|
获取用户信息(drive_id等)
|
||||||
"""
|
"""
|
||||||
|
if not self.aligo:
|
||||||
|
return {}
|
||||||
return self.aligo.get_user()
|
return self.aligo.get_user()
|
||||||
|
|
||||||
def __update_drives(self):
|
def __update_drives(self):
|
||||||
"""
|
"""
|
||||||
更新用户存储根目录
|
更新用户存储根目录
|
||||||
"""
|
"""
|
||||||
|
if not self.aligo:
|
||||||
|
return
|
||||||
drivers = self.aligo.list_my_drives()
|
drivers = self.aligo.list_my_drives()
|
||||||
for driver in drivers:
|
for driver in drivers:
|
||||||
if driver.category == "resource":
|
if driver.category == "resource":
|
||||||
@@ -240,28 +255,9 @@ class AliPan(StorageBase):
|
|||||||
return []
|
return []
|
||||||
# 根目录处理
|
# 根目录处理
|
||||||
if not fileitem or not fileitem.drive_id:
|
if not fileitem or not fileitem.drive_id:
|
||||||
return [
|
items = self.aligo.get_file_list()
|
||||||
schemas.FileItem(
|
if items:
|
||||||
storage=self.schema.value,
|
return [self.__get_fileitem(item) for item in items]
|
||||||
fileid="root",
|
|
||||||
drive_id=self.__auth_params.get("resourceDriveId"),
|
|
||||||
parent_fileid="root",
|
|
||||||
type="dir",
|
|
||||||
path="/资源库/",
|
|
||||||
name="资源库",
|
|
||||||
basename="资源库"
|
|
||||||
),
|
|
||||||
schemas.FileItem(
|
|
||||||
storage=self.schema.value,
|
|
||||||
fileid="root",
|
|
||||||
drive_id=self.__auth_params.get("backDriveId"),
|
|
||||||
parent_fileid="root",
|
|
||||||
type="dir",
|
|
||||||
path="/备份盘/",
|
|
||||||
name="备份盘",
|
|
||||||
basename="备份盘"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
elif fileitem.type == "file":
|
elif fileitem.type == "file":
|
||||||
# 文件处理
|
# 文件处理
|
||||||
file = self.detail(fileitem)
|
file = self.detail(fileitem)
|
||||||
@@ -276,6 +272,8 @@ class AliPan(StorageBase):
|
|||||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
创建目录
|
创建目录
|
||||||
|
:param fileitem: 父目录
|
||||||
|
:param name: 目录名
|
||||||
"""
|
"""
|
||||||
if not self.aligo:
|
if not self.aligo:
|
||||||
return None
|
return None
|
||||||
@@ -283,21 +281,43 @@ class AliPan(StorageBase):
|
|||||||
if item:
|
if item:
|
||||||
if isinstance(item, CreateFileResponse):
|
if isinstance(item, CreateFileResponse):
|
||||||
item = self.aligo.get_file(file_id=item.file_id, drive_id=item.drive_id)
|
item = self.aligo.get_file(file_id=item.file_id, drive_id=item.drive_id)
|
||||||
return self.__get_fileitem(item)
|
return self.__get_fileitem(item, parent=fileitem.path)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
根据文件路程获取目录,不存在则创建
|
根据文件路程获取目录,不存在则创建
|
||||||
"""
|
"""
|
||||||
if not self.aligo:
|
|
||||||
|
def __find_dir(_fileitem: schemas.FileItem, _name: str) -> Optional[schemas.FileItem]:
|
||||||
|
"""
|
||||||
|
查找下级目录中匹配名称的目录
|
||||||
|
"""
|
||||||
|
for sub_folder in self.list(_fileitem):
|
||||||
|
if sub_folder.type != "dir":
|
||||||
|
continue
|
||||||
|
if sub_folder.name == _name:
|
||||||
|
return sub_folder
|
||||||
return None
|
return None
|
||||||
item = self.aligo.get_folder_by_path(path=str(path), create_folder=True)
|
|
||||||
if item:
|
# 是否已存在
|
||||||
if isinstance(item, CreateFileResponse):
|
folder = self.get_item(path)
|
||||||
item = self.aligo.get_file(file_id=item.file_id, drive_id=item.drive_id)
|
if folder:
|
||||||
return self.__get_fileitem(item)
|
return folder
|
||||||
return None
|
# 逐级查找和创建目录
|
||||||
|
fileitem = schemas.FileItem(path="/")
|
||||||
|
for part in path.parts:
|
||||||
|
if part == "/":
|
||||||
|
continue
|
||||||
|
dir_file = __find_dir(fileitem, part)
|
||||||
|
if dir_file:
|
||||||
|
fileitem = dir_file
|
||||||
|
else:
|
||||||
|
dir_file = self.create_folder(fileitem, part)
|
||||||
|
if not dir_file:
|
||||||
|
return None
|
||||||
|
fileitem = dir_file
|
||||||
|
return fileitem
|
||||||
|
|
||||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
@@ -307,7 +327,7 @@ class AliPan(StorageBase):
|
|||||||
return None
|
return None
|
||||||
item = self.aligo.get_file_by_path(path=str(path))
|
item = self.aligo.get_file_by_path(path=str(path))
|
||||||
if item:
|
if item:
|
||||||
return self.__get_fileitem(item)
|
return self.__get_fileitem(item, parent=path.parent)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||||
@@ -328,7 +348,7 @@ class AliPan(StorageBase):
|
|||||||
return None
|
return None
|
||||||
item = self.aligo.get_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id)
|
item = self.aligo.get_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id)
|
||||||
if item:
|
if item:
|
||||||
return self.__get_fileitem(item)
|
return self.__get_fileitem(item, parent=fileitem.path)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||||
@@ -347,41 +367,66 @@ class AliPan(StorageBase):
|
|||||||
"""
|
"""
|
||||||
if not self.aligo:
|
if not self.aligo:
|
||||||
return None
|
return None
|
||||||
local_path = self.aligo.download_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id,
|
local_path = self.aligo.download_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id, # noqa
|
||||||
local_folder=str(path or settings.TEMP_PATH))
|
local_folder=str(path or settings.TEMP_PATH))
|
||||||
if local_path:
|
if local_path:
|
||||||
return Path(local_path)
|
return Path(local_path)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def upload(self, fileitem: schemas.FileItem, path: Path) -> Optional[schemas.FileItem]:
|
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: str = None) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
上传文件,并标记完成
|
上传文件,并标记完成
|
||||||
|
:param fileitem: 上传目录项
|
||||||
|
:param path: 本地文件路径
|
||||||
|
:param new_name: 上传后文件名
|
||||||
"""
|
"""
|
||||||
if not self.aligo:
|
if not self.aligo:
|
||||||
return None
|
return None
|
||||||
# 上传文件
|
# 上传文件
|
||||||
result = self.aligo.upload_file(file_path=str(path), parent_file_id=fileitem.fileid,
|
result = self.aligo.upload_file(file_path=str(path), parent_file_id=fileitem.fileid,
|
||||||
drive_id=fileitem.drive_id, name=path.name,
|
drive_id=fileitem.drive_id, name=new_name or path.name,
|
||||||
check_name_mode="refuse")
|
check_name_mode="refuse")
|
||||||
if result:
|
if result:
|
||||||
item = self.aligo.get_file(file_id=result.file_id, drive_id=result.drive_id)
|
item = self.aligo.get_file(file_id=result.file_id, drive_id=result.drive_id)
|
||||||
if item:
|
if item:
|
||||||
return self.__get_fileitem(item)
|
return self.__get_fileitem(item, parent=fileitem.path)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def move(self, fileitem: schemas.FileItem, target: schemas.FileItem) -> bool:
|
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
移动文件
|
移动文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
"""
|
"""
|
||||||
if not self.aligo:
|
if not self.aligo:
|
||||||
return False
|
return False
|
||||||
|
target = self.get_folder(path)
|
||||||
|
if not target:
|
||||||
|
return False
|
||||||
if self.aligo.move_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id,
|
if self.aligo.move_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id,
|
||||||
to_parent_file_id=target.fileid, to_drive_id=target.drive_id):
|
to_parent_file_id=target.fileid, to_drive_id=target.drive_id,
|
||||||
|
new_name=new_name):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def copy(self, fileitem: schemas.FileItem, target: schemas.FileItem) -> bool:
|
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
pass
|
"""
|
||||||
|
复制文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
|
"""
|
||||||
|
if not self.aligo:
|
||||||
|
return False
|
||||||
|
target = self.get_folder(path)
|
||||||
|
if not target:
|
||||||
|
return False
|
||||||
|
if self.aligo.copy_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id,
|
||||||
|
to_parent_file_id=target.fileid, to_drive_id=target.drive_id,
|
||||||
|
new_name=new_name):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple, List, Dict, Union
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
from requests import Response
|
|
||||||
from cachetools import cached, TTLCache
|
from cachetools import cached, TTLCache
|
||||||
|
from requests import Response
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -13,10 +13,11 @@ from app.log import logger
|
|||||||
from app.modules.filemanager.storages import StorageBase
|
from app.modules.filemanager.storages import StorageBase
|
||||||
from app.schemas.types import StorageSchema
|
from app.schemas.types import StorageSchema
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.url import UrlUtils
|
from app.utils.url import UrlUtils
|
||||||
|
|
||||||
|
|
||||||
class Alist(StorageBase):
|
class Alist(StorageBase, metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
Alist相关操作
|
Alist相关操作
|
||||||
api文档:https://alist.nn.ci/zh/guide/api
|
api文档:https://alist.nn.ci/zh/guide/api
|
||||||
@@ -34,6 +35,12 @@ class Alist(StorageBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
def init_storage(self):
|
||||||
|
"""
|
||||||
|
初始化
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __get_base_url(self) -> str:
|
def __get_base_url(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -57,9 +64,6 @@ class Alist(StorageBase):
|
|||||||
如果设置永久令牌则返回永久令牌
|
如果设置永久令牌则返回永久令牌
|
||||||
否则使用账号密码生成临时令牌
|
否则使用账号密码生成临时令牌
|
||||||
"""
|
"""
|
||||||
token = self.get_conf().get("token")
|
|
||||||
if token:
|
|
||||||
return token
|
|
||||||
return self.__generate_token
|
return self.__generate_token
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -216,8 +220,8 @@ class Alist(StorageBase):
|
|||||||
path=(Path(fileitem.path) / item["name"]).as_posix() + ("/" if item["is_dir"] else ""),
|
path=(Path(fileitem.path) / item["name"]).as_posix() + ("/" if item["is_dir"] else ""),
|
||||||
name=item["name"],
|
name=item["name"],
|
||||||
basename=Path(item["name"]).stem,
|
basename=Path(item["name"]).stem,
|
||||||
extension=Path(item["name"]).suffix,
|
extension=Path(item["name"]).suffix[1:] if not item["is_dir"] else None,
|
||||||
size=item["size"],
|
size=item["size"] if not item["is_dir"] else None,
|
||||||
modify_time=self.__parse_timestamp(item["modified"]),
|
modify_time=self.__parse_timestamp(item["modified"]),
|
||||||
thumbnail=item["thumb"],
|
thumbnail=item["thumb"],
|
||||||
)
|
)
|
||||||
@@ -229,6 +233,8 @@ class Alist(StorageBase):
|
|||||||
) -> Optional[schemas.FileItem]:
|
) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
创建目录
|
创建目录
|
||||||
|
:param fileitem: 父目录
|
||||||
|
:param name: 目录名
|
||||||
"""
|
"""
|
||||||
path = Path(fileitem.path) / name
|
path = Path(fileitem.path) / name
|
||||||
resp: Response = RequestUtils(
|
resp: Response = RequestUtils(
|
||||||
@@ -267,14 +273,16 @@ class Alist(StorageBase):
|
|||||||
获取目录,如目录不存在则创建
|
获取目录,如目录不存在则创建
|
||||||
"""
|
"""
|
||||||
folder = self.get_item(path)
|
folder = self.get_item(path)
|
||||||
|
if folder:
|
||||||
|
return folder
|
||||||
if not folder:
|
if not folder:
|
||||||
folder = self.create_folder(self.get_parent(schemas.FileItem(
|
folder = self.create_folder(schemas.FileItem(
|
||||||
storage=self.schema.value,
|
storage=self.schema.value,
|
||||||
type="dir",
|
type="dir",
|
||||||
path=path.as_posix() + "/",
|
path=path.parent.as_posix(),
|
||||||
name=path.name,
|
name=path.name,
|
||||||
basename=path.stem
|
basename=path.stem
|
||||||
)), path.name)
|
), path.name)
|
||||||
return folder
|
return folder
|
||||||
|
|
||||||
def get_item(
|
def get_item(
|
||||||
@@ -345,7 +353,7 @@ class Alist(StorageBase):
|
|||||||
|
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
if result["code"] != 200:
|
if result["code"] != 200:
|
||||||
logging.warning(f'获取文件 {path} 失败,错误信息:{result["message"]}')
|
logging.debug(f'获取文件 {path} 失败,错误信息:{result["message"]}')
|
||||||
return
|
return
|
||||||
|
|
||||||
return schemas.FileItem(
|
return schemas.FileItem(
|
||||||
@@ -354,7 +362,7 @@ class Alist(StorageBase):
|
|||||||
path=path.as_posix() + ("/" if result["data"]["is_dir"] else ""),
|
path=path.as_posix() + ("/" if result["data"]["is_dir"] else ""),
|
||||||
name=result["data"]["name"],
|
name=result["data"]["name"],
|
||||||
basename=Path(result["data"]["name"]).stem,
|
basename=Path(result["data"]["name"]).stem,
|
||||||
extension=Path(result["data"]["name"]).suffix,
|
extension=Path(result["data"]["name"]).suffix[1:],
|
||||||
size=result["data"]["size"],
|
size=result["data"]["size"],
|
||||||
modify_time=self.__parse_timestamp(result["data"]["modified"]),
|
modify_time=self.__parse_timestamp(result["data"]["modified"]),
|
||||||
thumbnail=result["data"]["thumb"],
|
thumbnail=result["data"]["thumb"],
|
||||||
@@ -373,7 +381,7 @@ class Alist(StorageBase):
|
|||||||
resp: Response = RequestUtils(
|
resp: Response = RequestUtils(
|
||||||
headers=self.__get_header_with_token()
|
headers=self.__get_header_with_token()
|
||||||
).post_res(
|
).post_res(
|
||||||
self.__get_api_url("/api/fs/delete"),
|
self.__get_api_url("/api/fs/remove"),
|
||||||
json={
|
json={
|
||||||
"dir": Path(fileitem.path).parent.as_posix(),
|
"dir": Path(fileitem.path).parent.as_posix(),
|
||||||
"names": [fileitem.name],
|
"names": [fileitem.name],
|
||||||
@@ -524,22 +532,25 @@ class Alist(StorageBase):
|
|||||||
).get_res(download_url)
|
).get_res(download_url)
|
||||||
|
|
||||||
if not path:
|
if not path:
|
||||||
path = settings.TEMP_PATH / fileitem.name
|
new_path = settings.TEMP_PATH / fileitem.name
|
||||||
|
else:
|
||||||
|
new_path = path / fileitem.name
|
||||||
|
|
||||||
with open(path, "wb") as f:
|
with open(new_path, "wb") as f:
|
||||||
f.write(resp.content)
|
f.write(resp.content)
|
||||||
|
|
||||||
if path.exists():
|
if new_path.exists():
|
||||||
return path
|
return new_path
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def upload(
|
def upload(
|
||||||
self, fileitem: schemas.FileItem, path: Path, task: bool = False
|
self, fileitem: schemas.FileItem, path: Path, new_name: str = None, task: bool = False
|
||||||
) -> Optional[schemas.FileItem]:
|
) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
上传文件
|
上传文件
|
||||||
:param fileitem: 上传目录项
|
:param fileitem: 上传目录项
|
||||||
:param path: 本地文件路径
|
:param path: 本地文件路径
|
||||||
|
:param new_name: 上传后文件名
|
||||||
:param task: 是否为任务,默认为False避免未完成上传时对文件进行操作
|
:param task: 是否为任务,默认为False避免未完成上传时对文件进行操作
|
||||||
"""
|
"""
|
||||||
encoded_path = UrlUtils.quote(fileitem.path)
|
encoded_path = UrlUtils.quote(fileitem.path)
|
||||||
@@ -557,7 +568,12 @@ class Alist(StorageBase):
|
|||||||
logging.warning(f"请求上传文件 {path} 失败,状态码:{resp.status_code}")
|
logging.warning(f"请求上传文件 {path} 失败,状态码:{resp.status_code}")
|
||||||
return
|
return
|
||||||
|
|
||||||
return fileitem
|
new_item = self.get_item(Path(fileitem.path) / path.name)
|
||||||
|
if 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))
|
||||||
|
|
||||||
|
return new_item
|
||||||
|
|
||||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
@@ -565,51 +581,21 @@ class Alist(StorageBase):
|
|||||||
"""
|
"""
|
||||||
return self.get_item(Path(fileitem.path))
|
return self.get_item(Path(fileitem.path))
|
||||||
|
|
||||||
@staticmethod
|
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
def __get_copy_and_move_data(
|
|
||||||
fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]
|
|
||||||
) -> Tuple[str, str, List[str], bool]:
|
|
||||||
"""
|
|
||||||
获取复制或移动文件需要的数据
|
|
||||||
|
|
||||||
:param fileitem: 文件项
|
|
||||||
:param target: 目标文件项或目标路径
|
|
||||||
:return: 源目录,目标目录,文件名列表,是否有效
|
|
||||||
"""
|
|
||||||
name = Path(target).name
|
|
||||||
if fileitem.name != name:
|
|
||||||
return "", "", [], False
|
|
||||||
|
|
||||||
src_dir = Path(fileitem.path).parent.as_posix()
|
|
||||||
if isinstance(target, schemas.FileItem):
|
|
||||||
traget_dir = Path(target.path).parent.as_posix()
|
|
||||||
else:
|
|
||||||
traget_dir = target.parent.as_posix()
|
|
||||||
|
|
||||||
return src_dir, traget_dir, [name], True
|
|
||||||
|
|
||||||
def copy(
|
|
||||||
self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]
|
|
||||||
) -> bool:
|
|
||||||
"""
|
"""
|
||||||
复制文件
|
复制文件
|
||||||
|
:param fileitem: 文件项
|
||||||
源文件名和目标文件名必须相同
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
"""
|
"""
|
||||||
src_dir, dst_dir, names, is_valid = self.__get_copy_and_move_data(
|
|
||||||
fileitem, target
|
|
||||||
)
|
|
||||||
if not is_valid:
|
|
||||||
return False
|
|
||||||
|
|
||||||
resp: Response = RequestUtils(
|
resp: Response = RequestUtils(
|
||||||
headers=self.__get_header_with_token()
|
headers=self.__get_header_with_token()
|
||||||
).post_res(
|
).post_res(
|
||||||
self.__get_api_url("/api/fs/copy"),
|
self.__get_api_url("/api/fs/copy"),
|
||||||
json={
|
json={
|
||||||
"src_dir": src_dir,
|
"src_dir": Path(fileitem.path).parent.as_posix(),
|
||||||
"dst_dir": dst_dir,
|
"dst_dir": path.as_posix(),
|
||||||
"names": names,
|
"names": [fileitem.name],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
@@ -644,28 +630,31 @@ class Alist(StorageBase):
|
|||||||
f'复制文件 {fileitem.path} 失败,错误信息:{result["message"]}'
|
f'复制文件 {fileitem.path} 失败,错误信息:{result["message"]}'
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
# 重命名
|
||||||
|
if fileitem.name != new_name:
|
||||||
|
self.rename(
|
||||||
|
self.get_item(path / fileitem.name), new_name
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def move(
|
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]
|
|
||||||
) -> bool:
|
|
||||||
"""
|
"""
|
||||||
移动文件
|
移动文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
"""
|
"""
|
||||||
src_dir, dst_dir, names, is_valid = self.__get_copy_and_move_data(
|
# 先重命名
|
||||||
fileitem, target
|
if fileitem.name != new_name:
|
||||||
)
|
self.rename(fileitem, new_name)
|
||||||
if not is_valid:
|
|
||||||
return False
|
|
||||||
|
|
||||||
resp: Response = RequestUtils(
|
resp: Response = RequestUtils(
|
||||||
headers=self.__get_header_with_token()
|
headers=self.__get_header_with_token()
|
||||||
).post_res(
|
).post_res(
|
||||||
self.__get_api_url("/api/fs/move"),
|
self.__get_api_url("/api/fs/move"),
|
||||||
json={
|
json={
|
||||||
"src_dir": src_dir,
|
"src_dir": Path(fileitem.path).parent.as_posix(),
|
||||||
"dst_dir": dst_dir,
|
"dst_dir": path.as_posix(),
|
||||||
"names": names,
|
"names": [new_name],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
@@ -746,15 +735,7 @@ class Alist(StorageBase):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __parse_timestamp(time_str: str) -> float:
|
def __parse_timestamp(time_str: str) -> float:
|
||||||
# try:
|
"""
|
||||||
# # 尝试解析带微秒的时间格式
|
直接使用 ISO 8601 格式解析时间
|
||||||
# dt = datetime.strptime(time_str[:26], '%Y-%m-%dT%H:%M:%S.%f')
|
"""
|
||||||
# except ValueError:
|
return datetime.fromisoformat(time_str).timestamp()
|
||||||
# # 如果失败,尝试解析不带微秒的时间格式
|
|
||||||
# dt = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ')
|
|
||||||
|
|
||||||
# 直接使用 ISO 8601 格式解析时间
|
|
||||||
dt = datetime.fromisoformat(time_str)
|
|
||||||
|
|
||||||
# 返回时间戳
|
|
||||||
return dt.timestamp()
|
|
||||||
|
|||||||
@@ -25,13 +25,19 @@ class LocalStorage(StorageBase):
|
|||||||
"softlink": "软链接"
|
"softlink": "软链接"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def init_storage(self):
|
||||||
|
"""
|
||||||
|
初始化
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
def check(self) -> bool:
|
def check(self) -> bool:
|
||||||
"""
|
"""
|
||||||
检查存储是否可用
|
检查存储是否可用
|
||||||
"""
|
"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def __get_fileitem(self, path: Path):
|
def __get_fileitem(self, path: Path) -> schemas.FileItem:
|
||||||
"""
|
"""
|
||||||
获取文件项
|
获取文件项
|
||||||
"""
|
"""
|
||||||
@@ -46,7 +52,7 @@ class LocalStorage(StorageBase):
|
|||||||
modify_time=path.stat().st_mtime,
|
modify_time=path.stat().st_mtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __get_diritem(self, path: Path):
|
def __get_diritem(self, path: Path) -> schemas.FileItem:
|
||||||
"""
|
"""
|
||||||
获取目录项
|
获取目录项
|
||||||
"""
|
"""
|
||||||
@@ -109,6 +115,8 @@ class LocalStorage(StorageBase):
|
|||||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
创建目录
|
创建目录
|
||||||
|
:param fileitem: 父目录
|
||||||
|
:param name: 目录名
|
||||||
"""
|
"""
|
||||||
if not fileitem.path:
|
if not fileitem.path:
|
||||||
return None
|
return None
|
||||||
@@ -183,28 +191,20 @@ class LocalStorage(StorageBase):
|
|||||||
"""
|
"""
|
||||||
return Path(fileitem.path)
|
return Path(fileitem.path)
|
||||||
|
|
||||||
def upload(self, fileitem: schemas.FileItem, path: Path) -> Optional[schemas.FileItem]:
|
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: str = None) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
上传文件
|
上传文件
|
||||||
|
:param fileitem: 上传目录项
|
||||||
|
:param path: 本地文件路径
|
||||||
|
:param new_name: 上传后文件名
|
||||||
"""
|
"""
|
||||||
dir_path = Path(fileitem.path)
|
dir_path = Path(fileitem.path)
|
||||||
target_path = dir_path / path.name
|
target_path = dir_path / (new_name or path.name)
|
||||||
code, message = SystemUtils.move(path, target_path)
|
code, message = SystemUtils.move(path, target_path)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
logger.error(f"移动文件失败:{message}")
|
logger.error(f"移动文件失败:{message}")
|
||||||
return None
|
return None
|
||||||
return self.__get_diritem(target_path)
|
return self.get_item(target_path)
|
||||||
|
|
||||||
def copy(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
|
||||||
"""
|
|
||||||
复制文件
|
|
||||||
"""
|
|
||||||
file_path = Path(fileitem.path)
|
|
||||||
code, message = SystemUtils.copy(file_path, target_file)
|
|
||||||
if code != 0:
|
|
||||||
logger.error(f"复制文件失败:{message}")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -228,12 +228,29 @@ class LocalStorage(StorageBase):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def move(self, fileitem: schemas.FileItem, target: Path) -> bool:
|
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
移动文件
|
复制文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
"""
|
"""
|
||||||
file_path = Path(fileitem.path)
|
file_path = Path(fileitem.path)
|
||||||
code, message = SystemUtils.move(file_path, target)
|
code, message = SystemUtils.copy(file_path, path / new_name)
|
||||||
|
if code != 0:
|
||||||
|
logger.error(f"复制文件失败:{message}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
|
"""
|
||||||
|
移动文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
|
"""
|
||||||
|
file_path = Path(fileitem.path)
|
||||||
|
code, message = SystemUtils.move(file_path, path / new_name)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
logger.error(f"移动文件失败:{message}")
|
logger.error(f"移动文件失败:{message}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ class Rclone(StorageBase):
|
|||||||
"copy": "复制"
|
"copy": "复制"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def init_storage(self):
|
||||||
|
"""
|
||||||
|
初始化
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
def set_config(self, conf: dict):
|
def set_config(self, conf: dict):
|
||||||
"""
|
"""
|
||||||
设置配置
|
设置配置
|
||||||
@@ -39,7 +45,7 @@ class Rclone(StorageBase):
|
|||||||
path = Path(filepath)
|
path = Path(filepath)
|
||||||
if not path.parent.exists():
|
if not path.parent.exists():
|
||||||
path.parent.mkdir(parents=True)
|
path.parent.mkdir(parents=True)
|
||||||
path.write_text(conf.get('content'))
|
path.write_text(conf.get('content'), encoding='utf-8')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_hidden_shell():
|
def __get_hidden_shell():
|
||||||
@@ -76,7 +82,7 @@ class Rclone(StorageBase):
|
|||||||
return schemas.FileItem(
|
return schemas.FileItem(
|
||||||
storage=self.schema.value,
|
storage=self.schema.value,
|
||||||
type="dir",
|
type="dir",
|
||||||
path=f"{parent}{item.get('Name')}",
|
path=f"{parent}{item.get('Name')}" + "/",
|
||||||
name=item.get("Name"),
|
name=item.get("Name"),
|
||||||
basename=item.get("Name"),
|
basename=item.get("Name"),
|
||||||
modify_time=StringUtils.str_to_timestamp(item.get("ModTime"))
|
modify_time=StringUtils.str_to_timestamp(item.get("ModTime"))
|
||||||
@@ -133,6 +139,8 @@ class Rclone(StorageBase):
|
|||||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
创建目录
|
创建目录
|
||||||
|
:param fileitem: 父目录
|
||||||
|
:param name: 目录名
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
retcode = subprocess.run(
|
retcode = subprocess.run(
|
||||||
@@ -143,10 +151,7 @@ class Rclone(StorageBase):
|
|||||||
startupinfo=self.__get_hidden_shell()
|
startupinfo=self.__get_hidden_shell()
|
||||||
).returncode
|
).returncode
|
||||||
if retcode == 0:
|
if retcode == 0:
|
||||||
ret_fileitem = copy.deepcopy(fileitem)
|
return self.get_item(Path(f"{fileitem.path}/{name}"))
|
||||||
ret_fileitem.path = f"{fileitem.path}/{name}/"
|
|
||||||
ret_fileitem.name = name
|
|
||||||
return ret_fileitem
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"rclone创建目录失败:{err}")
|
logger.error(f"rclone创建目录失败:{err}")
|
||||||
return None
|
return None
|
||||||
@@ -160,13 +165,17 @@ class Rclone(StorageBase):
|
|||||||
"""
|
"""
|
||||||
查找下级目录中匹配名称的目录
|
查找下级目录中匹配名称的目录
|
||||||
"""
|
"""
|
||||||
for sub_file in self.list(_fileitem):
|
for sub_folder in self.list(_fileitem):
|
||||||
if sub_file.type != "dir":
|
if sub_folder.type != "dir":
|
||||||
continue
|
continue
|
||||||
if sub_file.name == _name:
|
if sub_folder.name == _name:
|
||||||
return sub_file
|
return sub_folder
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# 是否已存在
|
||||||
|
folder = self.get_item(path)
|
||||||
|
if folder:
|
||||||
|
return folder
|
||||||
# 逐级查找和创建目录
|
# 逐级查找和创建目录
|
||||||
fileitem = schemas.FileItem(path="/")
|
fileitem = schemas.FileItem(path="/")
|
||||||
for part in path.parts:
|
for part in path.parts:
|
||||||
@@ -260,21 +269,25 @@ class Rclone(StorageBase):
|
|||||||
logger.error(f"rclone复制文件失败:{err}")
|
logger.error(f"rclone复制文件失败:{err}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def upload(self, fileitem: schemas.FileItem, path: Path) -> Optional[schemas.FileItem]:
|
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: str = None) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
上传文件
|
上传文件
|
||||||
|
:param fileitem: 上传目录项
|
||||||
|
:param path: 本地文件路径
|
||||||
|
:param new_name: 上传后文件名
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
new_path = Path(fileitem.path) / (new_name or path.name)
|
||||||
retcode = subprocess.run(
|
retcode = subprocess.run(
|
||||||
[
|
[
|
||||||
'rclone', 'copyto',
|
'rclone', 'copyto',
|
||||||
str(path),
|
str(path),
|
||||||
f'MP:{Path(fileitem.path) / path.name}'
|
f'MP:{new_path}'
|
||||||
],
|
],
|
||||||
startupinfo=self.__get_hidden_shell()
|
startupinfo=self.__get_hidden_shell()
|
||||||
).returncode
|
).returncode
|
||||||
if retcode == 0:
|
if retcode == 0:
|
||||||
return self.__get_fileitem(path)
|
return self.__get_fileitem(new_path)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"rclone上传文件失败:{err}")
|
logger.error(f"rclone上传文件失败:{err}")
|
||||||
return None
|
return None
|
||||||
@@ -299,16 +312,19 @@ class Rclone(StorageBase):
|
|||||||
logger.error(f"rclone获取文件详情失败:{err}")
|
logger.error(f"rclone获取文件详情失败:{err}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def move(self, fileitem: schemas.FileItem, target: Path) -> bool:
|
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
移动文件,target_file格式:rclone:path
|
移动文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
retcode = subprocess.run(
|
retcode = subprocess.run(
|
||||||
[
|
[
|
||||||
'rclone', 'moveto',
|
'rclone', 'moveto',
|
||||||
f'MP:{fileitem.path}',
|
f'MP:{fileitem.path}',
|
||||||
f'MP:{target}'
|
f'MP:{path / new_name}'
|
||||||
],
|
],
|
||||||
startupinfo=self.__get_hidden_shell()
|
startupinfo=self.__get_hidden_shell()
|
||||||
).returncode
|
).returncode
|
||||||
@@ -318,8 +334,27 @@ class Rclone(StorageBase):
|
|||||||
logger.error(f"rclone移动文件失败:{err}")
|
logger.error(f"rclone移动文件失败:{err}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def copy(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
pass
|
"""
|
||||||
|
复制文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
retcode = subprocess.run(
|
||||||
|
[
|
||||||
|
'rclone', 'copyto',
|
||||||
|
f'MP:{fileitem.path}',
|
||||||
|
f'MP:{path / new_name}'
|
||||||
|
],
|
||||||
|
startupinfo=self.__get_hidden_shell()
|
||||||
|
).returncode
|
||||||
|
if retcode == 0:
|
||||||
|
return True
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"rclone复制文件失败:{err}")
|
||||||
|
return False
|
||||||
|
|
||||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import base64
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple, List
|
from typing import Optional, Tuple, List
|
||||||
|
|
||||||
import oss2
|
from p115 import P115Client, P115Path
|
||||||
import py115
|
|
||||||
from py115 import Cloud
|
|
||||||
from py115.types import LoginTarget, QrcodeSession, QrcodeStatus, Credential
|
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -27,57 +22,54 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
|
|
||||||
# 支持的整理方式
|
# 支持的整理方式
|
||||||
transtype = {
|
transtype = {
|
||||||
"move": "移动"
|
"move": "移动",
|
||||||
|
"copy": "复制"
|
||||||
}
|
}
|
||||||
|
|
||||||
cloud: Optional[Cloud] = None
|
# 115二维码登录地址
|
||||||
_session: QrcodeSession = None
|
qrcode_url = "https://qrcodeapi.115.com/api/1.0/web/1.0/token/"
|
||||||
|
# 115登录状态检查
|
||||||
|
login_check_url = "https://qrcodeapi.115.com/get/status/"
|
||||||
|
# 115登录完成 alipaymini
|
||||||
|
login_done_api = f"https://passportapi.115.com/app/1.0/alipaymini/1.0/login/qrcode/"
|
||||||
|
|
||||||
# 是否有aria2c
|
client: P115Client = None
|
||||||
_has_aria2c: bool = False
|
session_info: dict = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
try:
|
self.init_storage()
|
||||||
subprocess.run(['aria2c', '-h'], capture_output=True)
|
|
||||||
self._has_aria2c = True
|
|
||||||
logger.debug('发现 aria2c, 将使用 aria2c 下载文件')
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.debug('未发现 aria2c')
|
|
||||||
self._has_aria2c = False
|
|
||||||
|
|
||||||
def __init_cloud(self) -> bool:
|
def init_storage(self):
|
||||||
"""
|
"""
|
||||||
初始化Cloud
|
初始化Cloud
|
||||||
"""
|
"""
|
||||||
credential = self.__credential
|
if not self.__credential:
|
||||||
if not credential:
|
return
|
||||||
logger.warn("115未登录,请先登录!")
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
if not self.cloud:
|
self.client = P115Client(self.__credential, app="alipaymini",
|
||||||
self.cloud = py115.connect(credential)
|
check_for_relogin=False, console_qrcode=False)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"115连接失败,请重新扫码登录:{str(err)}")
|
logger.error(f"115连接失败,请重新登录:{str(err)}")
|
||||||
self.__clear_credential()
|
self.__clear_credential()
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __credential(self) -> Optional[Credential]:
|
def __credential(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
获取已保存的115认证参数
|
获取已保存的115 Cookie
|
||||||
"""
|
"""
|
||||||
cookie_dict = self.get_config()
|
conf = self.get_config()
|
||||||
if not cookie_dict:
|
if not conf:
|
||||||
return None
|
return None
|
||||||
return Credential.from_dict(cookie_dict.dict().get("config"))
|
if not conf.config:
|
||||||
|
return None
|
||||||
|
return conf.config.get("cookie")
|
||||||
|
|
||||||
def __save_credential(self, credential: Credential):
|
def __save_credential(self, credential: dict):
|
||||||
"""
|
"""
|
||||||
设置115认证参数
|
设置115认证参数
|
||||||
"""
|
"""
|
||||||
self.set_config(credential.to_dict())
|
self.set_config(credential)
|
||||||
|
|
||||||
def __clear_credential(self):
|
def __clear_credential(self):
|
||||||
"""
|
"""
|
||||||
@@ -89,62 +81,75 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
生成二维码
|
生成二维码
|
||||||
"""
|
"""
|
||||||
try:
|
res = RequestUtils(timeout=10).get_res(self.qrcode_url)
|
||||||
self.cloud = py115.connect()
|
if res:
|
||||||
self._session = self.cloud.qrcode_login(LoginTarget.Web)
|
self.session_info = res.json().get("data")
|
||||||
image_bin = self._session.image_data
|
qrcode_content = self.session_info.pop("qrcode")
|
||||||
if not image_bin:
|
if not qrcode_content:
|
||||||
logger.warn("115生成二维码失败:未获取到二维码数据!")
|
logger.warn("115生成二维码失败:未获取到二维码数据!")
|
||||||
return None
|
return {}, ""
|
||||||
# 转换为base64图片格式
|
|
||||||
image_base64 = base64.b64encode(image_bin).decode()
|
|
||||||
return {
|
return {
|
||||||
"codeContent": f"data:image/jpeg;base64,{image_base64}"
|
"codeContent": qrcode_content
|
||||||
}, ""
|
}, ""
|
||||||
except Exception as e:
|
elif res is not None:
|
||||||
logger.warn(f"115生成二维码失败:{str(e)}")
|
return {}, f"115生成二维码失败:{res.status_code} - {res.reason}"
|
||||||
return {}, f"115生成二维码失败:{str(e)}"
|
return {}, f"115生成二维码失败:无法连接!"
|
||||||
|
|
||||||
def check_login(self) -> Optional[Tuple[dict, str]]:
|
def check_login(self) -> Optional[Tuple[dict, str]]:
|
||||||
"""
|
"""
|
||||||
二维码登录确认
|
二维码登录确认
|
||||||
"""
|
"""
|
||||||
if not self._session:
|
if not self.session_info:
|
||||||
return {}, "请先生成二维码!"
|
return {}, "请先生成二维码!"
|
||||||
try:
|
try:
|
||||||
if not self.cloud:
|
resp = RequestUtils(timeout=10).get_res(self.login_check_url, params=self.session_info)
|
||||||
return {}, "请先生成二维码!"
|
if not resp:
|
||||||
status = self.cloud.qrcode_poll(self._session)
|
return {}, "115登录确认失败:无法连接!"
|
||||||
if status == QrcodeStatus.Done:
|
result = resp.json()
|
||||||
# 确认完成,保存认证信息
|
match result["data"].get("status"):
|
||||||
self.__save_credential(self.cloud.export_credentail())
|
case 0:
|
||||||
result = {
|
result = {
|
||||||
"status": 1,
|
"status": 0,
|
||||||
"tip": "登录成功!"
|
"tip": "请使用微信或115客户端扫码"
|
||||||
}
|
}
|
||||||
elif status == QrcodeStatus.Waiting:
|
case 1:
|
||||||
result = {
|
result = {
|
||||||
"status": 0,
|
"status": 1,
|
||||||
"tip": "请使用微信或115客户端扫码"
|
"tip": "已扫码"
|
||||||
}
|
}
|
||||||
elif status == QrcodeStatus.Expired:
|
case 2:
|
||||||
result = {
|
# 确认完成,保存认证信息
|
||||||
"status": -1,
|
resp = RequestUtils(timeout=10).post_res(self.login_done_api,
|
||||||
"tip": "二维码已过期,请重新刷新!"
|
data={"account": self.session_info.get("uid")})
|
||||||
}
|
if not resp:
|
||||||
self.cloud = None
|
return {}, "115登录确认失败:无法连接!"
|
||||||
elif status == QrcodeStatus.Failed:
|
if resp:
|
||||||
result = {
|
# 保存认证信息
|
||||||
"status": -2,
|
result = resp.json()
|
||||||
"tip": "登录失败,请重试!"
|
cookie_dict = result["data"]["cookie"]
|
||||||
}
|
cookie_str = "; ".join([f"{k}={v}" for k, v in cookie_dict.items()])
|
||||||
self.cloud = None
|
cookie_dict.update({"cookie": cookie_str})
|
||||||
else:
|
self.__save_credential(cookie_dict)
|
||||||
result = {
|
self.init_storage()
|
||||||
"status": -3,
|
result = {
|
||||||
"tip": "未知错误,请重试!"
|
"status": 2,
|
||||||
}
|
"tip": "登录成功!"
|
||||||
self.cloud = None
|
}
|
||||||
|
case -1:
|
||||||
|
result = {
|
||||||
|
"status": -1,
|
||||||
|
"tip": "二维码已过期,请重新刷新!"
|
||||||
|
}
|
||||||
|
case -2:
|
||||||
|
result = {
|
||||||
|
"status": -2,
|
||||||
|
"tip": "登录失败,请重试!"
|
||||||
|
}
|
||||||
|
case _:
|
||||||
|
result = {
|
||||||
|
"status": -3,
|
||||||
|
"tip": "未知错误,请重试!"
|
||||||
|
}
|
||||||
return result, ""
|
return result, ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {}, f"115登录确认失败:{str(e)}"
|
return {}, f"115登录确认失败:{str(e)}"
|
||||||
@@ -153,10 +158,12 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
获取存储空间
|
获取存储空间
|
||||||
"""
|
"""
|
||||||
if not self.__init_cloud():
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return self.cloud.storage().space()
|
usage = self.client.fs.space_summury()
|
||||||
|
if usage:
|
||||||
|
return usage['rt_space_info']['all_total']['size'], usage['rt_space_info']['all_remain']['size']
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"115获取存储空间失败:{str(e)}")
|
logger.error(f"115获取存储空间失败:{str(e)}")
|
||||||
return None
|
return None
|
||||||
@@ -165,31 +172,27 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
检查存储是否可用
|
检查存储是否可用
|
||||||
"""
|
"""
|
||||||
return True if self.list(schemas.FileItem(
|
return True if self.list(schemas.FileItem()) else False
|
||||||
fileid="0"
|
|
||||||
)) else False
|
|
||||||
|
|
||||||
def list(self, fileitem: schemas.FileItem) -> Optional[List[schemas.FileItem]]:
|
def list(self, fileitem: schemas.FileItem) -> Optional[List[schemas.FileItem]]:
|
||||||
"""
|
"""
|
||||||
浏览文件
|
浏览文件
|
||||||
"""
|
"""
|
||||||
if not self.__init_cloud():
|
if not self.client:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
if fileitem.type == "file":
|
if fileitem.type == "file":
|
||||||
return [fileitem]
|
return [fileitem]
|
||||||
items = self.cloud.storage().list(dir_id=fileitem.fileid)
|
items: List[P115Path] = self.client.fs.list(fileitem.path)
|
||||||
return [schemas.FileItem(
|
return [schemas.FileItem(
|
||||||
storage=self.schema.value,
|
storage=self.schema.value,
|
||||||
fileid=item.file_id,
|
type="dir" if item.is_dir() else "file",
|
||||||
parent_fileid=item.parent_id,
|
path=item.path + ("/" if item.is_dir() else ""),
|
||||||
type="dir" if item.is_dir else "file",
|
|
||||||
path=f"{fileitem.path}{item.name}" + ("/" if item.is_dir else ""),
|
|
||||||
name=item.name,
|
name=item.name,
|
||||||
size=item.size,
|
basename=item.stem,
|
||||||
extension=Path(item.name).suffix[1:],
|
size=item.stat().st_size,
|
||||||
modify_time=item.modified_time.timestamp() if item.modified_time else 0,
|
extension=item.suffix[1:] if not item.is_dir() else None,
|
||||||
pickcode=item.pickcode
|
modify_time=item.stat().st_mtime
|
||||||
) for item in items if item]
|
) for item in items if item]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"115浏览文件失败:{str(e)}")
|
logger.error(f"115浏览文件失败:{str(e)}")
|
||||||
@@ -199,20 +202,18 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
创建目录
|
创建目录
|
||||||
"""
|
"""
|
||||||
if not self.__init_cloud():
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
result = self.cloud.storage().make_dir(fileitem.fileid, name)
|
result = self.client.fs.makedirs(Path(fileitem.path) / name, exist_ok=True)
|
||||||
if result:
|
if result:
|
||||||
return schemas.FileItem(
|
return schemas.FileItem(
|
||||||
storage=self.schema.value,
|
storage=self.schema.value,
|
||||||
fileid=result.file_id,
|
|
||||||
parent_fileid=result.parent_id,
|
|
||||||
type="dir",
|
type="dir",
|
||||||
path=f"{fileitem.path}{name}/",
|
path=f"{result.path}/",
|
||||||
name=name,
|
name=name,
|
||||||
modify_time=result.modified_time.timestamp() if result.modified_time else 0,
|
basename=Path(result.name).stem,
|
||||||
pickcode=result.pickcode
|
modify_time=result.mtime
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"115创建目录失败:{str(e)}")
|
logger.error(f"115创建目录失败:{str(e)}")
|
||||||
@@ -222,73 +223,86 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
根据文件路程获取目录,不存在则创建
|
根据文件路程获取目录,不存在则创建
|
||||||
"""
|
"""
|
||||||
|
if not self.client:
|
||||||
def __find_dir(_fileitem: schemas.FileItem, _name: str) -> Optional[schemas.FileItem]:
|
|
||||||
"""
|
|
||||||
查找下级目录中匹配名称的目录
|
|
||||||
"""
|
|
||||||
for sub_file in self.list(_fileitem):
|
|
||||||
if sub_file.type != "dir":
|
|
||||||
continue
|
|
||||||
if sub_file.name == _name:
|
|
||||||
return sub_file
|
|
||||||
return None
|
return None
|
||||||
|
folder = self.get_item(path)
|
||||||
# 逐级查找和创建目录
|
if folder:
|
||||||
fileitem = schemas.FileItem(fileid="0")
|
return folder
|
||||||
for part in path.parts:
|
try:
|
||||||
if part == "/":
|
result = self.client.fs.makedirs(path, exist_ok=True)
|
||||||
continue
|
if result:
|
||||||
dir_file = __find_dir(fileitem, part)
|
return schemas.FileItem(
|
||||||
if dir_file:
|
storage=self.schema.value,
|
||||||
fileitem = dir_file
|
type="dir",
|
||||||
else:
|
path=result.path + "/",
|
||||||
dir_file = self.create_folder(fileitem, part)
|
name=result.name,
|
||||||
if not dir_file:
|
basename=Path(result.name).stem,
|
||||||
logger.warn(f"115创建目录 {fileitem.path}{part} 失败!")
|
modify_time=result.mtime
|
||||||
return None
|
)
|
||||||
fileitem = dir_file
|
except Exception as e:
|
||||||
return fileitem if fileitem.fileid != "0" else None
|
logger.error(f"115获取目录失败:{str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
获取文件或目录,不存在返回None
|
获取文件或目录,不存在返回None
|
||||||
"""
|
"""
|
||||||
|
if not self.client:
|
||||||
def __find_item(_fileitem: schemas.FileItem, _name: str) -> Optional[schemas.FileItem]:
|
|
||||||
"""
|
|
||||||
查找下级目录中匹配名称的目录或文件
|
|
||||||
"""
|
|
||||||
for sub_file in self.list(_fileitem):
|
|
||||||
if sub_file.name == _name:
|
|
||||||
return sub_file
|
|
||||||
return None
|
return None
|
||||||
|
try:
|
||||||
# 逐级查找
|
try:
|
||||||
fileitem = schemas.FileItem(fileid="0")
|
item = self.client.fs.attr(path)
|
||||||
for part in path.parts:
|
except FileNotFoundError:
|
||||||
if part == "/":
|
|
||||||
continue
|
|
||||||
item = __find_item(fileitem, part)
|
|
||||||
if not item:
|
|
||||||
return None
|
return None
|
||||||
fileitem = item
|
if item:
|
||||||
return fileitem
|
return schemas.FileItem(
|
||||||
|
storage=self.schema.value,
|
||||||
|
type="dir" if item.is_directory else "file",
|
||||||
|
path=item.path + ("/" if item.is_directory else ""),
|
||||||
|
name=item.name,
|
||||||
|
size=item.size,
|
||||||
|
extension=Path(item.name).suffix[1:] if not item.is_directory else None,
|
||||||
|
modify_time=item.mtime,
|
||||||
|
thumbnail=item.get("thumb")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"115获取文件失败:{str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
获取文件详情
|
获取文件详情
|
||||||
"""
|
"""
|
||||||
pass
|
if not self.client:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
item = self.client.fs.attr(fileitem.path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
if item:
|
||||||
|
return schemas.FileItem(
|
||||||
|
storage=self.schema.value,
|
||||||
|
type="dir" if item.is_directory else "file",
|
||||||
|
path=item.path + ("/" if item.is_directory else ""),
|
||||||
|
name=item.name,
|
||||||
|
size=item.size,
|
||||||
|
extension=Path(item.name).suffix[1:] if not item.is_directory else None,
|
||||||
|
modify_time=item.mtime,
|
||||||
|
thumbnail=item.get("thumb")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"115获取文件详情失败:{str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||||
"""
|
"""
|
||||||
删除文件
|
删除文件
|
||||||
"""
|
"""
|
||||||
if not self.__init_cloud():
|
if not self.client:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
self.cloud.storage().delete(fileitem.fileid)
|
self.client.fs.remove(fileitem.path)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"115删除文件失败:{str(e)}")
|
logger.error(f"115删除文件失败:{str(e)}")
|
||||||
@@ -298,10 +312,10 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
重命名文件
|
重命名文件
|
||||||
"""
|
"""
|
||||||
if not self.__init_cloud():
|
if not self.client:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
self.cloud.storage().rename(fileitem.fileid, name)
|
self.client.fs.rename(fileitem.path, Path(fileitem.path).with_name(name))
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"115重命名文件失败:{str(e)}")
|
logger.error(f"115重命名文件失败:{str(e)}")
|
||||||
@@ -311,89 +325,77 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
获取下载链接
|
获取下载链接
|
||||||
"""
|
"""
|
||||||
if not self.__init_cloud():
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
|
local_file = (path or settings.TEMP_PATH) / fileitem.name
|
||||||
try:
|
try:
|
||||||
ticket = self.cloud.storage().request_download(fileitem.pickcode)
|
task = self.client.fs.download(fileitem.path, file=local_file)
|
||||||
if ticket:
|
if task:
|
||||||
path = (path or settings.TEMP_PATH) / fileitem.name
|
return local_file
|
||||||
res = RequestUtils(headers=ticket.headers).get_res(ticket.url)
|
|
||||||
if res:
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(res.content)
|
|
||||||
return path
|
|
||||||
else:
|
|
||||||
logger.warn(f"{fileitem.path} 未获取到下载链接")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"115下载失败:{str(e)}")
|
logger.error(f"115下载文件失败:{str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def upload(self, fileitem: schemas.FileItem, path: Path) -> Optional[schemas.FileItem]:
|
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: str = None) -> Optional[schemas.FileItem]:
|
||||||
"""
|
"""
|
||||||
上传文件
|
上传文件
|
||||||
|
:param fileitem: 上传目录项
|
||||||
|
:param path: 本地文件路径
|
||||||
|
:param new_name: 上传后文件名
|
||||||
"""
|
"""
|
||||||
if not self.__init_cloud():
|
if not self.client:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
ticket = self.cloud.storage().request_upload(dir_id=fileitem.fileid, file_path=str(path))
|
new_path = Path(fileitem.path) / (new_name or path.name)
|
||||||
if ticket is None:
|
with open(path, "rb") as f:
|
||||||
logger.warn(f"115请求上传出错")
|
result = self.client.fs.upload(f, new_path)
|
||||||
return None
|
|
||||||
elif ticket.is_done:
|
|
||||||
file_path = Path(fileitem.path) / path.name
|
|
||||||
logger.warn(f"115上传:{file_path} 文件已存在")
|
|
||||||
return self.get_item(file_path)
|
|
||||||
else:
|
|
||||||
auth = oss2.StsAuth(**ticket.oss_token)
|
|
||||||
bucket = oss2.Bucket(
|
|
||||||
auth=auth,
|
|
||||||
endpoint=ticket.oss_endpoint,
|
|
||||||
bucket_name=ticket.bucket_name,
|
|
||||||
)
|
|
||||||
por = bucket.put_object_from_file(
|
|
||||||
key=ticket.object_key,
|
|
||||||
filename=str(path),
|
|
||||||
headers=ticket.headers,
|
|
||||||
)
|
|
||||||
result = por.resp.response.json()
|
|
||||||
if result:
|
if result:
|
||||||
result_data = result.get('data')
|
|
||||||
logger.info(f"115上传文件成功:{result_data.get('file_name')}")
|
|
||||||
return schemas.FileItem(
|
return schemas.FileItem(
|
||||||
storage=self.schema.value,
|
storage=self.schema.value,
|
||||||
fileid=result_data.get('file_id'),
|
|
||||||
parent_fileid=fileitem.fileid,
|
|
||||||
type="file",
|
type="file",
|
||||||
name=result_data.get('file_name'),
|
path=str(path),
|
||||||
basename=Path(result_data.get('file_name')).stem,
|
name=result.name,
|
||||||
path=f"{fileitem.path}{result_data.get('file_name')}",
|
basename=Path(result.name).stem,
|
||||||
size=result_data.get('file_size'),
|
size=result.size,
|
||||||
extension=Path(result_data.get('file_name')).suffix[1:],
|
extension=Path(result.name).suffix[1:],
|
||||||
pickcode=result_data.get('pickcode')
|
modify_time=result.mtime
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
logger.warn(f"115上传文件失败:{por.resp.response.text}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"115上传文件失败:{str(e)}")
|
logger.error(f"115上传文件失败:{str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def move(self, fileitem: schemas.FileItem, target: schemas.FileItem) -> bool:
|
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
移动文件
|
复制文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
"""
|
"""
|
||||||
if not self.__init_cloud():
|
if not self.client:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
self.cloud.storage().move(fileitem.fileid, target.fileid)
|
self.client.fs.copy(fileitem.path, path / new_name)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"115复制文件失败:{str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
|
"""
|
||||||
|
移动文件
|
||||||
|
:param fileitem: 文件项
|
||||||
|
:param path: 目标目录
|
||||||
|
:param new_name: 新文件名
|
||||||
|
"""
|
||||||
|
if not self.client:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.client.fs.move(fileitem.path, path / new_name)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"115移动文件失败:{str(e)}")
|
logger.error(f"115移动文件失败:{str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def copy(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -406,9 +408,9 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
info = self.storage()
|
info = self.storage()
|
||||||
if info:
|
if info:
|
||||||
total, used = info
|
total, free = info
|
||||||
return schemas.StorageUsage(
|
return schemas.StorageUsage(
|
||||||
total=total,
|
total=total,
|
||||||
available=total - used
|
available=free
|
||||||
)
|
)
|
||||||
return schemas.StorageUsage()
|
return schemas.StorageUsage()
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ class IndexerModule(_ModuleBase):
|
|||||||
site_ua=site.get("ua"),
|
site_ua=site.get("ua"),
|
||||||
site_proxy=site.get("proxy"),
|
site_proxy=site.get("proxy"),
|
||||||
site_order=site.get("pri"),
|
site_order=site.get("pri"),
|
||||||
|
site_downloader=site.get("downloader"),
|
||||||
**result) for result in result_array]
|
**result) for result in result_array]
|
||||||
# 去重
|
# 去重
|
||||||
return __remove_duplicate(torrents)
|
return __remove_duplicate(torrents)
|
||||||
@@ -199,7 +200,7 @@ class IndexerModule(_ModuleBase):
|
|||||||
def __spider_search(indexer: CommentedMap,
|
def __spider_search(indexer: CommentedMap,
|
||||||
search_word: str = None,
|
search_word: str = None,
|
||||||
mtype: MediaType = None,
|
mtype: MediaType = None,
|
||||||
page: int = 0) -> (bool, List[dict]):
|
page: int = 0) -> Tuple[bool, List[dict]]:
|
||||||
"""
|
"""
|
||||||
根据关键字搜索单个站点
|
根据关键字搜索单个站点
|
||||||
:param: indexer: 站点配置
|
:param: indexer: 站点配置
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ class SiteParserBase(metaclass=ABCMeta):
|
|||||||
# 未读消息
|
# 未读消息
|
||||||
self.message_unread = 0
|
self.message_unread = 0
|
||||||
self.message_unread_contents = []
|
self.message_unread_contents = []
|
||||||
|
self.message_read_force = False
|
||||||
|
|
||||||
# 全局附加请求头
|
# 全局附加请求头
|
||||||
self._addition_headers = None
|
self._addition_headers = None
|
||||||
@@ -202,7 +203,7 @@ class SiteParserBase(metaclass=ABCMeta):
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
unread_msg_links = []
|
unread_msg_links = []
|
||||||
if self.message_unread > 0:
|
if self.message_unread > 0 or self.message_read_force:
|
||||||
links = {self._user_mail_unread_page, self._sys_mail_unread_page}
|
links = {self._user_mail_unread_page, self._sys_mail_unread_page}
|
||||||
for link in links:
|
for link in links:
|
||||||
if not link:
|
if not link:
|
||||||
@@ -226,7 +227,7 @@ class SiteParserBase(metaclass=ABCMeta):
|
|||||||
)
|
)
|
||||||
unread_msg_links.extend(msg_links)
|
unread_msg_links.extend(msg_links)
|
||||||
# 重新更新未读消息数(99999表示有消息但数量未知)
|
# 重新更新未读消息数(99999表示有消息但数量未知)
|
||||||
if self.message_unread == 99999:
|
if unread_msg_links and not self.message_unread:
|
||||||
self.message_unread = len(unread_msg_links)
|
self.message_unread = len(unread_msg_links)
|
||||||
# 解析未读消息内容
|
# 解析未读消息内容
|
||||||
for msg_link in unread_msg_links:
|
for msg_link in unread_msg_links:
|
||||||
@@ -343,11 +344,9 @@ class SiteParserBase(metaclass=ABCMeta):
|
|||||||
logger.warn(
|
logger.warn(
|
||||||
f"{self._site_name} 检测到Cloudflare,请更新Cookie和UA")
|
f"{self._site_name} 检测到Cloudflare,请更新Cookie和UA")
|
||||||
return ""
|
return ""
|
||||||
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
|
return RequestUtils.get_decoded_html_content(res,
|
||||||
res.encoding = "utf-8"
|
settings.ENCODING_DETECTION_PERFORMANCE_MODE,
|
||||||
else:
|
settings.ENCODING_DETECTION_MIN_CONFIDENCE)
|
||||||
res.encoding = res.apparent_encoding
|
|
||||||
return res.text
|
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -91,9 +91,7 @@ class MTorrentSiteUserInfo(SiteParserBase):
|
|||||||
self.download = int(user_info.get("memberCount", {}).get("downloaded") or '0')
|
self.download = int(user_info.get("memberCount", {}).get("downloaded") or '0')
|
||||||
self.ratio = user_info.get("memberCount", {}).get("shareRate") or 0
|
self.ratio = user_info.get("memberCount", {}).get("shareRate") or 0
|
||||||
self.bonus = user_info.get("memberCount", {}).get("bonus") or 0
|
self.bonus = user_info.get("memberCount", {}).get("bonus") or 0
|
||||||
# 需要解析消息,但不确定消息条数
|
self.message_read_force = True
|
||||||
self.message_unread = 99999
|
|
||||||
|
|
||||||
self._torrent_seeding_params = {
|
self._torrent_seeding_params = {
|
||||||
"pageNumber": 1,
|
"pageNumber": 1,
|
||||||
"pageSize": 200,
|
"pageSize": 200,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import traceback
|
|||||||
from typing import List
|
from typing import List
|
||||||
from urllib.parse import quote, urlencode, urlparse, parse_qs
|
from urllib.parse import quote, urlencode, urlparse, parse_qs
|
||||||
|
|
||||||
import chardet
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from pyquery import PyQuery
|
from pyquery import PyQuery
|
||||||
from ruamel.yaml import CommentedMap
|
from ruamel.yaml import CommentedMap
|
||||||
@@ -250,27 +249,9 @@ class TorrentSpider:
|
|||||||
referer=self.referer,
|
referer=self.referer,
|
||||||
proxies=self.proxies
|
proxies=self.proxies
|
||||||
).get_res(searchurl, allow_redirects=True)
|
).get_res(searchurl, allow_redirects=True)
|
||||||
if ret is not None:
|
page_source = RequestUtils.get_decoded_html_content(ret,
|
||||||
# 使用chardet检测字符编码
|
settings.ENCODING_DETECTION_PERFORMANCE_MODE,
|
||||||
raw_data = ret.content
|
settings.ENCODING_DETECTION_MIN_CONFIDENCE)
|
||||||
if raw_data:
|
|
||||||
try:
|
|
||||||
result = chardet.detect(raw_data)
|
|
||||||
encoding = result['encoding']
|
|
||||||
# 解码为字符串
|
|
||||||
page_source = raw_data.decode(encoding)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"chardet解码失败:{str(e)}")
|
|
||||||
# 探测utf-8解码
|
|
||||||
if re.search(r"charset=\"?utf-8\"?", ret.text, re.IGNORECASE):
|
|
||||||
ret.encoding = "utf-8"
|
|
||||||
else:
|
|
||||||
ret.encoding = ret.apparent_encoding
|
|
||||||
page_source = ret.text
|
|
||||||
else:
|
|
||||||
page_source = ret.text
|
|
||||||
else:
|
|
||||||
page_source = ""
|
|
||||||
|
|
||||||
# 解析
|
# 解析
|
||||||
return self.parse(page_source)
|
return self.parse(page_source)
|
||||||
|
|||||||
@@ -21,14 +21,30 @@ class YemaSpider:
|
|||||||
_cookie = None
|
_cookie = None
|
||||||
_ua = None
|
_ua = None
|
||||||
_size = 40
|
_size = 40
|
||||||
_searchurl = "%sapi/torrent/fetchCategoryOpenTorrentList"
|
_searchurl = "%sapi/torrent/fetchOpenTorrentList"
|
||||||
_downloadurl = "%sapi/torrent/download?id=%s"
|
_downloadurl = "%sapi/torrent/download?id=%s"
|
||||||
_pageurl = "%s#/torrent/detail/%s/"
|
_pageurl = "%s#/torrent/detail/%s/"
|
||||||
_timeout = 15
|
_timeout = 15
|
||||||
|
|
||||||
# 分类
|
# 分类
|
||||||
_movie_category = 4
|
_movie_category = [4]
|
||||||
_tv_category = 5
|
_tv_category = [5, 13, 14, 17, 15, 6, 16]
|
||||||
|
|
||||||
|
# 标签 https://wiki.yemapt.org/developer/constants
|
||||||
|
_labels = {
|
||||||
|
"1": "禁转",
|
||||||
|
"2": "首发",
|
||||||
|
"3": "官方",
|
||||||
|
"4": "自制",
|
||||||
|
"5": "国语",
|
||||||
|
"6": "中字",
|
||||||
|
"7": "粤语",
|
||||||
|
"8": "英字",
|
||||||
|
"9": "HDR10",
|
||||||
|
"10": "杜比视界",
|
||||||
|
"11": "分集",
|
||||||
|
"12": "完结",
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, indexer: CommentedMap):
|
def __init__(self, indexer: CommentedMap):
|
||||||
self.systemconfig = SystemConfigOper()
|
self.systemconfig = SystemConfigOper()
|
||||||
@@ -47,14 +63,7 @@ class YemaSpider:
|
|||||||
"""
|
"""
|
||||||
搜索
|
搜索
|
||||||
"""
|
"""
|
||||||
if not mtype:
|
|
||||||
categoryId = self._movie_category
|
|
||||||
elif mtype == MediaType.TV:
|
|
||||||
categoryId = self._tv_category
|
|
||||||
else:
|
|
||||||
categoryId = self._movie_category
|
|
||||||
params = {
|
params = {
|
||||||
"categoryId": categoryId,
|
|
||||||
"pageParam": {
|
"pageParam": {
|
||||||
"current": page + 1,
|
"current": page + 1,
|
||||||
"pageSize": self._size,
|
"pageSize": self._size,
|
||||||
@@ -62,6 +71,12 @@ class YemaSpider:
|
|||||||
},
|
},
|
||||||
"sorter": {}
|
"sorter": {}
|
||||||
}
|
}
|
||||||
|
# 新接口可不传 categoryId 参数
|
||||||
|
# if mtype == MediaType.MOVIE:
|
||||||
|
# params.update({
|
||||||
|
# "categoryId": self._movie_category,
|
||||||
|
# })
|
||||||
|
# pass
|
||||||
if keyword:
|
if keyword:
|
||||||
params.update({
|
params.update({
|
||||||
"keyword": keyword,
|
"keyword": keyword,
|
||||||
@@ -82,17 +97,27 @@ class YemaSpider:
|
|||||||
results = res.json().get('data', []) or []
|
results = res.json().get('data', []) or []
|
||||||
for result in results:
|
for result in results:
|
||||||
category_value = result.get('categoryId')
|
category_value = result.get('categoryId')
|
||||||
if category_value == self._tv_category:
|
if category_value in self._tv_category :
|
||||||
category = MediaType.TV.value
|
category = MediaType.TV.value
|
||||||
elif category_value == self._movie_category:
|
elif category_value in self._movie_category:
|
||||||
category = MediaType.MOVIE.value
|
category = MediaType.MOVIE.value
|
||||||
else:
|
else:
|
||||||
category = MediaType.UNKNOWN.value
|
category = MediaType.UNKNOWN.value
|
||||||
|
pass
|
||||||
|
|
||||||
|
torrentLabelIds = result.get('tagList', []) or []
|
||||||
|
torrentLabels = []
|
||||||
|
for labelId in torrentLabelIds:
|
||||||
|
if self._labels.get(labelId) is not None:
|
||||||
|
torrentLabels.append(self._labels.get(labelId))
|
||||||
|
pass
|
||||||
|
pass
|
||||||
torrent = {
|
torrent = {
|
||||||
'title': result.get('showName'),
|
'title': result.get('showName'),
|
||||||
'description': result.get('shortDesc'),
|
'description': result.get('shortDesc'),
|
||||||
'enclosure': self.__get_download_url(result.get('id')),
|
'enclosure': self.__get_download_url(result.get('id')),
|
||||||
'pubdate': StringUtils.unify_datetime_str(result.get('gmtCreate')),
|
# 使用上架时间,而不是用户发布时间,上架时间即其他用户可见时间
|
||||||
|
'pubdate': StringUtils.unify_datetime_str(result.get('listingTime')),
|
||||||
'size': result.get('fileSize'),
|
'size': result.get('fileSize'),
|
||||||
'seeders': result.get('seedNum'),
|
'seeders': result.get('seedNum'),
|
||||||
'peers': result.get('leechNum'),
|
'peers': result.get('leechNum'),
|
||||||
@@ -101,7 +126,7 @@ class YemaSpider:
|
|||||||
'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('uploadPromotion')),
|
'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('uploadPromotion')),
|
||||||
'freedate': StringUtils.unify_datetime_str(result.get('downloadPromotionEndTime')),
|
'freedate': StringUtils.unify_datetime_str(result.get('downloadPromotionEndTime')),
|
||||||
'page_url': self._pageurl % (self._domain, result.get('id')),
|
'page_url': self._pageurl % (self._domain, result.get('id')),
|
||||||
'labels': [],
|
'labels': torrentLabels,
|
||||||
'category': category
|
'category': category
|
||||||
}
|
}
|
||||||
torrents.append(torrent)
|
torrents.append(torrent)
|
||||||
|
|||||||
@@ -162,26 +162,26 @@ class Plex:
|
|||||||
def get_medias_count(self) -> schemas.Statistic:
|
def get_medias_count(self) -> schemas.Statistic:
|
||||||
"""
|
"""
|
||||||
获得电影、电视剧、动漫媒体数量
|
获得电影、电视剧、动漫媒体数量
|
||||||
:return: MovieCount SeriesCount SongCount
|
:return: movie_count tv_count episode_count
|
||||||
"""
|
"""
|
||||||
if not self._plex:
|
if not self._plex:
|
||||||
return schemas.Statistic()
|
return schemas.Statistic()
|
||||||
sections = self._plex.library.sections()
|
sections = self._plex.library.sections()
|
||||||
MovieCount = SeriesCount = EpisodeCount = 0
|
movie_count = tv_count = episode_count = 0
|
||||||
# 媒体库白名单
|
# 媒体库白名单
|
||||||
allow_library = [lib.id for lib in self.get_librarys(hidden=True)]
|
allow_library = [lib.id for lib in self.get_librarys(hidden=True)]
|
||||||
for sec in sections:
|
for sec in sections:
|
||||||
if str(sec.key) not in allow_library:
|
if sec.key not in allow_library:
|
||||||
continue
|
continue
|
||||||
if sec.type == "movie":
|
if sec.type == "movie":
|
||||||
MovieCount += sec.totalSize
|
movie_count += sec.totalSize
|
||||||
if sec.type == "show":
|
if sec.type == "show":
|
||||||
SeriesCount += sec.totalSize
|
tv_count += sec.totalSize
|
||||||
EpisodeCount += sec.totalViewSize(libtype='episode')
|
episode_count += sec.totalViewSize(libtype="episode")
|
||||||
return schemas.Statistic(
|
return schemas.Statistic(
|
||||||
movie_count=MovieCount,
|
movie_count=movie_count,
|
||||||
tv_count=SeriesCount,
|
tv_count=tv_count,
|
||||||
episode_count=EpisodeCount
|
episode_count=episode_count
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_movies(self,
|
def get_movies(self,
|
||||||
@@ -294,7 +294,7 @@ class Plex:
|
|||||||
return videos.key, season_episodes
|
return videos.key, season_episodes
|
||||||
|
|
||||||
def get_remote_image_by_id(self,
|
def get_remote_image_by_id(self,
|
||||||
item_id: str,
|
item_id: str,
|
||||||
image_type: str,
|
image_type: str,
|
||||||
depth: int = 0,
|
depth: int = 0,
|
||||||
plex_url: bool = True) -> Optional[str]:
|
plex_url: bool = True) -> Optional[str]:
|
||||||
@@ -310,12 +310,16 @@ class Plex:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
image_url = None
|
image_url = None
|
||||||
ekey = f"/library/metadata/{item_id}"
|
ekey = item_id
|
||||||
item = self._plex.fetchItem(ekey=ekey)
|
item = self._plex.fetchItem(ekey=ekey)
|
||||||
if not item:
|
if not item:
|
||||||
return None
|
return None
|
||||||
# 如果配置了外网播放地址以及Token,则默认从Plex媒体服务器获取图片,否则返回有外网地址的图片资源
|
# 如果配置了外网播放地址以及Token,则默认从Plex媒体服务器获取图片,否则返回有外网地址的图片资源
|
||||||
if self._playhost and self._token and plex_url:
|
# Plex外网播放地址这个框里目前可以填两种地址
|
||||||
|
# 1. Plex的官方转发地址https://app.plex.tv, 2. 自己处理的端口转发地址
|
||||||
|
# 如果使用的是1的官方转发地址,那么就不能走这个逻辑,因为官方转发地址无法获取到图片
|
||||||
|
if (self._playhost and "app.plex.tv" not in self._playhost
|
||||||
|
and self._token and plex_url):
|
||||||
query = {"X-Plex-Token": self._token}
|
query = {"X-Plex-Token": self._token}
|
||||||
if image_type == "Poster":
|
if image_type == "Poster":
|
||||||
if item.thumb:
|
if item.thumb:
|
||||||
@@ -346,8 +350,8 @@ class Plex:
|
|||||||
image_url = image.key
|
image_url = image.key
|
||||||
break
|
break
|
||||||
# 如果最后还是找不到,则递归父级进行查找
|
# 如果最后还是找不到,则递归父级进行查找
|
||||||
if not image_url and hasattr(item, "parentRatingKey"):
|
if not image_url and hasattr(item, "parentKey"):
|
||||||
return self.get_remote_image_by_id(item_id=item.parentRatingKey,
|
return self.get_remote_image_by_id(item_id=item.parentKey,
|
||||||
image_type=image_type,
|
image_type=image_type,
|
||||||
depth=depth + 1)
|
depth=depth + 1)
|
||||||
return image_url
|
return image_url
|
||||||
@@ -665,7 +669,7 @@ class Plex:
|
|||||||
"S" + str(message.get('Metadata', {}).get('parentIndex')),
|
"S" + str(message.get('Metadata', {}).get('parentIndex')),
|
||||||
"E" + str(message.get('Metadata', {}).get('index')),
|
"E" + str(message.get('Metadata', {}).get('index')),
|
||||||
message.get('Metadata', {}).get('title'))
|
message.get('Metadata', {}).get('title'))
|
||||||
eventItem.item_id = message.get('Metadata', {}).get('ratingKey')
|
eventItem.item_id = message.get('Metadata', {}).get('key')
|
||||||
eventItem.season_id = message.get('Metadata', {}).get('parentIndex')
|
eventItem.season_id = message.get('Metadata', {}).get('parentIndex')
|
||||||
eventItem.episode_id = message.get('Metadata', {}).get('index')
|
eventItem.episode_id = message.get('Metadata', {}).get('index')
|
||||||
|
|
||||||
@@ -680,7 +684,7 @@ class Plex:
|
|||||||
eventItem.item_name = "%s %s" % (
|
eventItem.item_name = "%s %s" % (
|
||||||
message.get('Metadata', {}).get('title'),
|
message.get('Metadata', {}).get('title'),
|
||||||
"(" + str(message.get('Metadata', {}).get('year')) + ")")
|
"(" + str(message.get('Metadata', {}).get('year')) + ")")
|
||||||
eventItem.item_id = message.get('Metadata', {}).get('ratingKey')
|
eventItem.item_id = message.get('Metadata', {}).get('key')
|
||||||
if len(message.get('Metadata', {}).get('summary')) > 100:
|
if len(message.get('Metadata', {}).get('summary')) > 100:
|
||||||
eventItem.overview = str(message.get('Metadata', {}).get('summary'))[:100] + "..."
|
eventItem.overview = str(message.get('Metadata', {}).get('summary'))[:100] + "..."
|
||||||
else:
|
else:
|
||||||
@@ -721,7 +725,7 @@ class Plex:
|
|||||||
if not self._plex:
|
if not self._plex:
|
||||||
return []
|
return []
|
||||||
# 媒体库白名单
|
# 媒体库白名单
|
||||||
allow_library = ",".join([lib.id for lib in self.get_librarys(hidden=True)])
|
allow_library = ",".join(map(str, (lib.id for lib in self.get_librarys(hidden=True))))
|
||||||
params = {"contentDirectoryID": allow_library}
|
params = {"contentDirectoryID": allow_library}
|
||||||
items = self._plex.fetchItems("/hubs/continueWatching/items",
|
items = self._plex.fetchItems("/hubs/continueWatching/items",
|
||||||
container_start=0,
|
container_start=0,
|
||||||
@@ -757,7 +761,7 @@ class Plex:
|
|||||||
if not self._plex:
|
if not self._plex:
|
||||||
return None
|
return None
|
||||||
# 请求参数(除黑名单)
|
# 请求参数(除黑名单)
|
||||||
allow_library = ",".join([lib.id for lib in self.get_librarys(hidden=True)])
|
allow_library = ",".join(map(str, (lib.id for lib in self.get_librarys(hidden=True))))
|
||||||
params = {
|
params = {
|
||||||
"contentDirectoryID": allow_library,
|
"contentDirectoryID": allow_library,
|
||||||
"count": num,
|
"count": num,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Set, Tuple, Optional, Union, List
|
from typing import Set, Tuple, Optional, Union, List, Dict
|
||||||
|
|
||||||
from qbittorrentapi import TorrentFilesList
|
from qbittorrentapi import TorrentFilesList
|
||||||
from torrentool.torrent import Torrent
|
from torrentool.torrent import Torrent
|
||||||
@@ -124,7 +124,8 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
|||||||
is_paused=is_paused,
|
is_paused=is_paused,
|
||||||
tag=tags,
|
tag=tags,
|
||||||
cookie=cookie,
|
cookie=cookie,
|
||||||
category=category
|
category=category,
|
||||||
|
ignore_category_check=False
|
||||||
)
|
)
|
||||||
if not state:
|
if not state:
|
||||||
# 读取种子的名称
|
# 读取种子的名称
|
||||||
@@ -203,66 +204,75 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
|||||||
:return: 下载器中符合状态的种子列表
|
:return: 下载器中符合状态的种子列表
|
||||||
"""
|
"""
|
||||||
# 获取下载器
|
# 获取下载器
|
||||||
server: Qbittorrent = self.get_instance(downloader)
|
if downloader:
|
||||||
if not server:
|
server: Qbittorrent = self.get_instance(downloader)
|
||||||
return None
|
if not server:
|
||||||
|
return None
|
||||||
|
servers = {downloader: server}
|
||||||
|
else:
|
||||||
|
servers: Dict[str, Qbittorrent] = self.get_instances()
|
||||||
ret_torrents = []
|
ret_torrents = []
|
||||||
if hashs:
|
if hashs:
|
||||||
# 按Hash获取
|
# 按Hash获取
|
||||||
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG)
|
for name, server in servers.items():
|
||||||
for torrent in torrents or []:
|
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG)
|
||||||
content_path = torrent.get("content_path")
|
for torrent in torrents or []:
|
||||||
if content_path:
|
content_path = torrent.get("content_path")
|
||||||
torrent_path = Path(content_path)
|
if content_path:
|
||||||
else:
|
torrent_path = Path(content_path)
|
||||||
torrent_path = Path(torrent.get('save_path')) / torrent.get('name')
|
else:
|
||||||
ret_torrents.append(TransferTorrent(
|
torrent_path = Path(torrent.get('save_path')) / torrent.get('name')
|
||||||
title=torrent.get('name'),
|
ret_torrents.append(TransferTorrent(
|
||||||
path=torrent_path,
|
downloader=name,
|
||||||
hash=torrent.get('hash'),
|
title=torrent.get('name'),
|
||||||
size=torrent.get('total_size'),
|
path=torrent_path,
|
||||||
tags=torrent.get('tags')
|
hash=torrent.get('hash'),
|
||||||
))
|
size=torrent.get('total_size'),
|
||||||
|
tags=torrent.get('tags')
|
||||||
|
))
|
||||||
elif status == TorrentStatus.TRANSFER:
|
elif status == TorrentStatus.TRANSFER:
|
||||||
# 获取已完成且未整理的
|
# 获取已完成且未整理的
|
||||||
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG)
|
for name, server in servers.items():
|
||||||
for torrent in torrents or []:
|
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG)
|
||||||
tags = torrent.get("tags") or []
|
for torrent in torrents or []:
|
||||||
if "已整理" in tags:
|
tags = torrent.get("tags") or []
|
||||||
continue
|
if "已整理" in tags:
|
||||||
# 内容路径
|
continue
|
||||||
content_path = torrent.get("content_path")
|
# 内容路径
|
||||||
if content_path:
|
content_path = torrent.get("content_path")
|
||||||
torrent_path = Path(content_path)
|
if content_path:
|
||||||
else:
|
torrent_path = Path(content_path)
|
||||||
torrent_path = torrent.get('save_path') / torrent.get('name')
|
else:
|
||||||
ret_torrents.append(TransferTorrent(
|
torrent_path = torrent.get('save_path') / torrent.get('name')
|
||||||
title=torrent.get('name'),
|
ret_torrents.append(TransferTorrent(
|
||||||
path=torrent_path,
|
downloader=name,
|
||||||
hash=torrent.get('hash'),
|
title=torrent.get('name'),
|
||||||
tags=torrent.get('tags')
|
path=torrent_path,
|
||||||
))
|
hash=torrent.get('hash'),
|
||||||
|
tags=torrent.get('tags')
|
||||||
|
))
|
||||||
elif status == TorrentStatus.DOWNLOADING:
|
elif status == TorrentStatus.DOWNLOADING:
|
||||||
# 获取正在下载的任务
|
# 获取正在下载的任务
|
||||||
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG)
|
for name, server in servers.items():
|
||||||
for torrent in torrents or []:
|
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG)
|
||||||
meta = MetaInfo(torrent.get('name'))
|
for torrent in torrents or []:
|
||||||
ret_torrents.append(DownloadingTorrent(
|
meta = MetaInfo(torrent.get('name'))
|
||||||
hash=torrent.get('hash'),
|
ret_torrents.append(DownloadingTorrent(
|
||||||
title=torrent.get('name'),
|
downloader=name,
|
||||||
name=meta.name,
|
hash=torrent.get('hash'),
|
||||||
year=meta.year,
|
title=torrent.get('name'),
|
||||||
season_episode=meta.season_episode,
|
name=meta.name,
|
||||||
progress=torrent.get('progress') * 100,
|
year=meta.year,
|
||||||
size=torrent.get('total_size'),
|
season_episode=meta.season_episode,
|
||||||
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
|
progress=torrent.get('progress') * 100,
|
||||||
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
|
size=torrent.get('total_size'),
|
||||||
upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
|
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
|
||||||
left_time=StringUtils.str_secends(
|
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
|
||||||
(torrent.get('total_size') - torrent.get('completed')) / torrent.get('dlspeed')) if torrent.get(
|
upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
|
||||||
'dlspeed') > 0 else ''
|
left_time=StringUtils.str_secends(
|
||||||
))
|
(torrent.get('total_size') - torrent.get('completed')) / torrent.get('dlspeed')) if torrent.get(
|
||||||
|
'dlspeed') > 0 else ''
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
return ret_torrents
|
return ret_torrents
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class Qbittorrent:
|
|||||||
try:
|
try:
|
||||||
qbt.auth_log_in()
|
qbt.auth_log_in()
|
||||||
except (qbittorrentapi.LoginFailed, qbittorrentapi.Forbidden403Error) as e:
|
except (qbittorrentapi.LoginFailed, qbittorrentapi.Forbidden403Error) as e:
|
||||||
logger.error(f"qbittorrent 登录失败:{str(e)}")
|
logger.error(f"qbittorrent 登录失败:{str(e).strip() or '请检查用户名和密码是否正确'}")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
stack_trace = "".join(traceback.format_exception(None, e, e.__traceback__))[:2000]
|
stack_trace = "".join(traceback.format_exception(None, e, e.__traceback__))[:2000]
|
||||||
@@ -251,6 +251,7 @@ class Qbittorrent:
|
|||||||
:param category: 种子分类
|
:param category: 种子分类
|
||||||
:param download_dir: 下载路径
|
:param download_dir: 下载路径
|
||||||
:param cookie: 站点Cookie用于辅助下载种子
|
:param cookie: 站点Cookie用于辅助下载种子
|
||||||
|
:param kwargs: 可选参数,如 ignore_category_check 以及 QB相关参数
|
||||||
:return: bool
|
:return: bool
|
||||||
"""
|
"""
|
||||||
if not self.qbc or not content:
|
if not self.qbc or not content:
|
||||||
@@ -276,13 +277,16 @@ class Qbittorrent:
|
|||||||
else:
|
else:
|
||||||
tags = None
|
tags = None
|
||||||
|
|
||||||
# 分类自动管理
|
# 如果忽略分类检查,则直接使用传入的分类值,否则,仅在分类存在且启用了自动管理时才传递参数
|
||||||
if category and self._category:
|
ignore_category_check = kwargs.pop("ignore_category_check", True)
|
||||||
is_auto = True
|
if ignore_category_check:
|
||||||
|
is_auto = self._category
|
||||||
else:
|
else:
|
||||||
is_auto = False
|
if category and self._category:
|
||||||
category = None
|
is_auto = True
|
||||||
|
else:
|
||||||
|
is_auto = False
|
||||||
|
category = None
|
||||||
try:
|
try:
|
||||||
# 添加下载
|
# 添加下载
|
||||||
qbc_ret = self.qbc.torrents_add(urls=urls,
|
qbc_ret = self.qbc.torrents_add(urls=urls,
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ class TmdbScraper:
|
|||||||
# 电影元数据文件
|
# 电影元数据文件
|
||||||
doc = self.__gen_movie_nfo_file(mediainfo=mediainfo)
|
doc = self.__gen_movie_nfo_file(mediainfo=mediainfo)
|
||||||
else:
|
else:
|
||||||
if season:
|
if season is not None:
|
||||||
# 查询季信息
|
# 查询季信息
|
||||||
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, meta.begin_season)
|
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, season)
|
||||||
if episode:
|
if episode:
|
||||||
# 集元数据文件
|
# 集元数据文件
|
||||||
episodeinfo = self.__get_episode_detail(seasoninfo, meta.begin_episode)
|
episodeinfo = self.__get_episode_detail(seasoninfo, meta.begin_episode)
|
||||||
@@ -57,7 +57,7 @@ class TmdbScraper:
|
|||||||
:param episode: 集号
|
:param episode: 集号
|
||||||
"""
|
"""
|
||||||
images = {}
|
images = {}
|
||||||
if season:
|
if season is not None:
|
||||||
# 只需要集的图片
|
# 只需要集的图片
|
||||||
if episode:
|
if episode:
|
||||||
# 集的图片
|
# 集的图片
|
||||||
@@ -102,8 +102,13 @@ class TmdbScraper:
|
|||||||
ext = Path(seasoninfo.get('poster_path')).suffix
|
ext = Path(seasoninfo.get('poster_path')).suffix
|
||||||
# URL
|
# URL
|
||||||
url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{seasoninfo.get('poster_path')}"
|
url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{seasoninfo.get('poster_path')}"
|
||||||
image_name = f"season{sea_seq}-poster{ext}"
|
# S0海报格式不同
|
||||||
|
if season == 0:
|
||||||
|
image_name = f"season-specials-poster{ext}"
|
||||||
|
else:
|
||||||
|
image_name = f"season{sea_seq}-poster{ext}"
|
||||||
return image_name, url
|
return image_name, url
|
||||||
|
return "", ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_episode_detail(seasoninfo: dict, episode: int) -> dict:
|
def __get_episode_detail(seasoninfo: dict, episode: int) -> dict:
|
||||||
@@ -228,7 +233,7 @@ class TmdbScraper:
|
|||||||
xoutline = DomUtils.add_node(doc, root, "outline")
|
xoutline = DomUtils.add_node(doc, root, "outline")
|
||||||
xoutline.appendChild(doc.createCDATASection(seasoninfo.get("overview") or ""))
|
xoutline.appendChild(doc.createCDATASection(seasoninfo.get("overview") or ""))
|
||||||
# 标题
|
# 标题
|
||||||
DomUtils.add_node(doc, root, "title", "季 %s" % season)
|
DomUtils.add_node(doc, root, "title", seasoninfo.get("name") or "季 %s" % season)
|
||||||
# 发行日期
|
# 发行日期
|
||||||
DomUtils.add_node(doc, root, "premiered", seasoninfo.get("air_date") or "")
|
DomUtils.add_node(doc, root, "premiered", seasoninfo.get("air_date") or "")
|
||||||
DomUtils.add_node(doc, root, "releasedate", seasoninfo.get("air_date") or "")
|
DomUtils.add_node(doc, root, "releasedate", seasoninfo.get("air_date") or "")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from app.schemas.types import MediaType
|
|||||||
lock = RLock()
|
lock = RLock()
|
||||||
|
|
||||||
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
|
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
|
||||||
EXPIRE_TIMESTAMP = settings.CACHE_CONF.get('meta')
|
EXPIRE_TIMESTAMP = settings.CACHE_CONF["meta"]
|
||||||
|
|
||||||
|
|
||||||
class TmdbCache(metaclass=Singleton):
|
class TmdbCache(metaclass=Singleton):
|
||||||
@@ -75,7 +75,7 @@ class TmdbCache(metaclass=Singleton):
|
|||||||
@return: 被删除的缓存内容
|
@return: 被删除的缓存内容
|
||||||
"""
|
"""
|
||||||
with lock:
|
with lock:
|
||||||
return self._meta_data.pop(key, None)
|
return self._meta_data.pop(key, {})
|
||||||
|
|
||||||
def delete_by_tmdbid(self, tmdbid: int) -> None:
|
def delete_by_tmdbid(self, tmdbid: int) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -138,14 +138,14 @@ class TmdbCache(metaclass=Singleton):
|
|||||||
if cache_year:
|
if cache_year:
|
||||||
cache_year = cache_year[:4]
|
cache_year = cache_year[:4]
|
||||||
self._meta_data[self.__get_key(meta)] = {
|
self._meta_data[self.__get_key(meta)] = {
|
||||||
"id": info.get("id"),
|
"id": info.get("id"),
|
||||||
"type": info.get("media_type"),
|
"type": info.get("media_type"),
|
||||||
"year": cache_year,
|
"year": cache_year,
|
||||||
"title": cache_title,
|
"title": cache_title,
|
||||||
"poster_path": info.get("poster_path"),
|
"poster_path": info.get("poster_path"),
|
||||||
"backdrop_path": info.get("backdrop_path"),
|
"backdrop_path": info.get("backdrop_path"),
|
||||||
CACHE_EXPIRE_TIMESTAMP_STR: int(time.time()) + EXPIRE_TIMESTAMP
|
CACHE_EXPIRE_TIMESTAMP_STR: int(time.time()) + EXPIRE_TIMESTAMP
|
||||||
}
|
}
|
||||||
elif info is not None:
|
elif info is not None:
|
||||||
# None时不缓存,此时代表网络错误,允许重复请求
|
# None时不缓存,此时代表网络错误,允许重复请求
|
||||||
self._meta_data[self.__get_key(meta)] = {'id': 0}
|
self._meta_data[self.__get_key(meta)] = {'id': 0}
|
||||||
@@ -164,7 +164,7 @@ class TmdbCache(metaclass=Singleton):
|
|||||||
return
|
return
|
||||||
|
|
||||||
with open(self._meta_path, 'wb') as f:
|
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) # type: ignore
|
||||||
|
|
||||||
def _random_sample(self, new_meta_data: dict) -> bool:
|
def _random_sample(self, new_meta_data: dict) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import traceback
|
import traceback
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import zhconv
|
import zhconv
|
||||||
|
from cachetools import TTLCache, cached
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -27,8 +27,6 @@ class TmdbApi:
|
|||||||
self.tmdb.domain = settings.TMDB_API_DOMAIN
|
self.tmdb.domain = settings.TMDB_API_DOMAIN
|
||||||
# 开启缓存
|
# 开启缓存
|
||||||
self.tmdb.cache = True
|
self.tmdb.cache = True
|
||||||
# 缓存大小
|
|
||||||
self.tmdb.REQUEST_CACHE_MAXSIZE = settings.CACHE_CONF.get('tmdb')
|
|
||||||
# APIKEY
|
# APIKEY
|
||||||
self.tmdb.api_key = settings.TMDB_API_KEY
|
self.tmdb.api_key = settings.TMDB_API_KEY
|
||||||
# 语种
|
# 语种
|
||||||
@@ -466,7 +464,7 @@ class TmdbApi:
|
|||||||
|
|
||||||
return ret_info
|
return ret_info
|
||||||
|
|
||||||
@lru_cache(maxsize=settings.CACHE_CONF.get('tmdb'))
|
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"]))
|
||||||
def match_web(self, name: str, mtype: MediaType) -> Optional[dict]:
|
def match_web(self, name: str, mtype: MediaType) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
搜索TMDB网站,直接抓取结果,结果只有一条时才返回
|
搜索TMDB网站,直接抓取结果,结果只有一条时才返回
|
||||||
@@ -1292,7 +1290,7 @@ class TmdbApi:
|
|||||||
for group_episode in group_episodes:
|
for group_episode in group_episodes:
|
||||||
order = group_episode.get('order')
|
order = group_episode.get('order')
|
||||||
episodes = group_episode.get('episodes')
|
episodes = group_episode.get('episodes')
|
||||||
if not episodes or not order:
|
if not episodes:
|
||||||
continue
|
continue
|
||||||
# 当前季第一季时间
|
# 当前季第一季时间
|
||||||
first_date = episodes[0].get("air_date")
|
first_date = episodes[0].get("air_date")
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import requests.exceptions
|
import requests.exceptions
|
||||||
|
from cachetools import TTLCache, cached
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from .exceptions import TMDbException
|
from .exceptions import TMDbException
|
||||||
|
|
||||||
@@ -24,7 +25,6 @@ class TMDb(object):
|
|||||||
TMDB_CACHE_ENABLED = "TMDB_CACHE_ENABLED"
|
TMDB_CACHE_ENABLED = "TMDB_CACHE_ENABLED"
|
||||||
TMDB_PROXIES = "TMDB_PROXIES"
|
TMDB_PROXIES = "TMDB_PROXIES"
|
||||||
TMDB_DOMAIN = "TMDB_DOMAIN"
|
TMDB_DOMAIN = "TMDB_DOMAIN"
|
||||||
REQUEST_CACHE_MAXSIZE = None
|
|
||||||
|
|
||||||
_req = None
|
_req = None
|
||||||
_session = None
|
_session = None
|
||||||
@@ -137,7 +137,7 @@ class TMDb(object):
|
|||||||
def cache(self, cache):
|
def cache(self, cache):
|
||||||
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
|
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
|
||||||
|
|
||||||
@lru_cache(maxsize=REQUEST_CACHE_MAXSIZE)
|
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"]))
|
||||||
def cached_request(self, method, url, data, json,
|
def cached_request(self, method, url, data, json,
|
||||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Set, Tuple, Optional, Union, List
|
from typing import Set, Tuple, Optional, Union, List, Dict
|
||||||
|
|
||||||
from torrentool.torrent import Torrent
|
from torrentool.torrent import Torrent
|
||||||
from transmission_rpc import File
|
from transmission_rpc import File
|
||||||
@@ -196,60 +196,70 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
|||||||
:return: 下载器中符合状态的种子列表
|
:return: 下载器中符合状态的种子列表
|
||||||
"""
|
"""
|
||||||
# 获取下载器
|
# 获取下载器
|
||||||
server: Transmission = self.get_instance(downloader)
|
if downloader:
|
||||||
if not server:
|
server: Transmission = self.get_instance(downloader)
|
||||||
return None
|
if not server:
|
||||||
|
return None
|
||||||
|
servers = {downloader: server}
|
||||||
|
else:
|
||||||
|
servers: Dict[str, Transmission] = self.get_instances()
|
||||||
ret_torrents = []
|
ret_torrents = []
|
||||||
if hashs:
|
if hashs:
|
||||||
# 按Hash获取
|
# 按Hash获取
|
||||||
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG)
|
for name, server in servers.items():
|
||||||
for torrent in torrents or []:
|
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG)
|
||||||
ret_torrents.append(TransferTorrent(
|
for torrent in torrents or []:
|
||||||
title=torrent.name,
|
ret_torrents.append(TransferTorrent(
|
||||||
path=Path(torrent.download_dir) / torrent.name,
|
downloader=name,
|
||||||
hash=torrent.hashString,
|
title=torrent.name,
|
||||||
size=torrent.total_size,
|
path=Path(torrent.download_dir) / torrent.name,
|
||||||
tags=",".join(torrent.labels or [])
|
hash=torrent.hashString,
|
||||||
))
|
size=torrent.total_size,
|
||||||
|
tags=",".join(torrent.labels or [])
|
||||||
|
))
|
||||||
elif status == TorrentStatus.TRANSFER:
|
elif status == TorrentStatus.TRANSFER:
|
||||||
# 获取已完成且未整理的
|
# 获取已完成且未整理的
|
||||||
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG)
|
for name, server in servers.items():
|
||||||
for torrent in torrents or []:
|
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG)
|
||||||
# 含"已整理"tag的不处理
|
for torrent in torrents or []:
|
||||||
if "已整理" in torrent.labels or []:
|
# 含"已整理"tag的不处理
|
||||||
continue
|
if "已整理" in torrent.labels or []:
|
||||||
# 下载路径
|
continue
|
||||||
path = torrent.download_dir
|
# 下载路径
|
||||||
# 无法获取下载路径的不处理
|
path = torrent.download_dir
|
||||||
if not path:
|
# 无法获取下载路径的不处理
|
||||||
logger.debug(f"未获取到 {torrent.name} 下载保存路径")
|
if not path:
|
||||||
continue
|
logger.debug(f"未获取到 {torrent.name} 下载保存路径")
|
||||||
ret_torrents.append(TransferTorrent(
|
continue
|
||||||
title=torrent.name,
|
ret_torrents.append(TransferTorrent(
|
||||||
path=Path(torrent.download_dir) / torrent.name,
|
downloader=name,
|
||||||
hash=torrent.hashString,
|
title=torrent.name,
|
||||||
tags=",".join(torrent.labels or [])
|
path=Path(torrent.download_dir) / torrent.name,
|
||||||
))
|
hash=torrent.hashString,
|
||||||
|
tags=",".join(torrent.labels or [])
|
||||||
|
))
|
||||||
elif status == TorrentStatus.DOWNLOADING:
|
elif status == TorrentStatus.DOWNLOADING:
|
||||||
# 获取正在下载的任务
|
# 获取正在下载的任务
|
||||||
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG)
|
for name, server in servers.items():
|
||||||
for torrent in torrents or []:
|
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG)
|
||||||
meta = MetaInfo(torrent.name)
|
for torrent in torrents or []:
|
||||||
dlspeed = torrent.rate_download if hasattr(torrent, "rate_download") else torrent.rateDownload
|
meta = MetaInfo(torrent.name)
|
||||||
upspeed = torrent.rate_upload if hasattr(torrent, "rate_upload") else torrent.rateUpload
|
dlspeed = torrent.rate_download if hasattr(torrent, "rate_download") else torrent.rateDownload
|
||||||
ret_torrents.append(DownloadingTorrent(
|
upspeed = torrent.rate_upload if hasattr(torrent, "rate_upload") else torrent.rateUpload
|
||||||
hash=torrent.hashString,
|
ret_torrents.append(DownloadingTorrent(
|
||||||
title=torrent.name,
|
downloader=name,
|
||||||
name=meta.name,
|
hash=torrent.hashString,
|
||||||
year=meta.year,
|
title=torrent.name,
|
||||||
season_episode=meta.season_episode,
|
name=meta.name,
|
||||||
progress=torrent.progress,
|
year=meta.year,
|
||||||
size=torrent.total_size,
|
season_episode=meta.season_episode,
|
||||||
state="paused" if torrent.status == "stopped" else "downloading",
|
progress=torrent.progress,
|
||||||
dlspeed=StringUtils.str_filesize(dlspeed),
|
size=torrent.total_size,
|
||||||
upspeed=StringUtils.str_filesize(upspeed),
|
state="paused" if torrent.status == "stopped" else "downloading",
|
||||||
left_time=StringUtils.str_secends(torrent.left_until_done / dlspeed) if dlspeed > 0 else ''
|
dlspeed=StringUtils.str_filesize(dlspeed),
|
||||||
))
|
upspeed=StringUtils.str_filesize(upspeed),
|
||||||
|
left_time=StringUtils.str_secends(torrent.left_until_done / dlspeed) if dlspeed > 0 else ''
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
return ret_torrents
|
return ret_torrents
|
||||||
|
|||||||
@@ -280,9 +280,9 @@ class Monitor(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
获取BDMV目录的上级目录
|
获取BDMV目录的上级目录
|
||||||
"""
|
"""
|
||||||
for parent in _path.parents:
|
for p in _path.parents:
|
||||||
if parent.name == "BDMV":
|
if p.name == "BDMV":
|
||||||
return parent.parent
|
return p.parent
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 全程加锁
|
# 全程加锁
|
||||||
@@ -478,13 +478,7 @@ class Monitor(metaclass=Singleton):
|
|||||||
|
|
||||||
# 移动模式删除空目录
|
# 移动模式删除空目录
|
||||||
if transferinfo.transfer_type in ["move"]:
|
if transferinfo.transfer_type in ["move"]:
|
||||||
if file_item.type == "dir":
|
self.storagechain.delete_media_file(file_item, delete_self=False)
|
||||||
folder_item = file_item
|
|
||||||
else:
|
|
||||||
folder_item = self.storagechain.get_parent_item(file_item)
|
|
||||||
if folder_item and not self.storagechain.any_files(folder_item, extensions=settings.RMT_MEDIAEXT):
|
|
||||||
logger.warn(f"删除残留空文件夹:【{folder_item.storage}】{folder_item.path}")
|
|
||||||
self.storagechain.delete_file(folder_item)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc()))
|
logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc()))
|
||||||
|
|||||||
132
app/scheduler.py
132
app/scheduler.py
@@ -19,10 +19,11 @@ from app.chain.transfer import TransferChain
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.event import EventManager
|
from app.core.event import EventManager
|
||||||
from app.core.plugin import PluginManager
|
from app.core.plugin import PluginManager
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.helper.sites import SitesHelper
|
from app.helper.sites import SitesHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import Notification, NotificationType
|
from app.schemas import Notification, NotificationType
|
||||||
from app.schemas.types import EventType
|
from app.schemas.types import EventType, SystemConfigKey
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.timer import TimerUtils
|
from app.utils.timer import TimerUtils
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ class Scheduler(metaclass=Singleton):
|
|||||||
# 退出事件
|
# 退出事件
|
||||||
_event = threading.Event()
|
_event = threading.Event()
|
||||||
# 锁
|
# 锁
|
||||||
_lock = threading.Lock()
|
_lock = threading.RLock()
|
||||||
# 各服务的运行状态
|
# 各服务的运行状态
|
||||||
_jobs = {}
|
_jobs = {}
|
||||||
# 用户认证失败次数
|
# 用户认证失败次数
|
||||||
@@ -53,49 +54,6 @@ class Scheduler(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
初始化定时服务
|
初始化定时服务
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def clear_cache():
|
|
||||||
"""
|
|
||||||
清理缓存
|
|
||||||
"""
|
|
||||||
TorrentsChain().clear_cache()
|
|
||||||
SchedulerChain().clear_cache()
|
|
||||||
|
|
||||||
def user_auth():
|
|
||||||
"""
|
|
||||||
用户认证检查
|
|
||||||
"""
|
|
||||||
if SitesHelper().auth_level >= 2:
|
|
||||||
return
|
|
||||||
# 最大重试次数
|
|
||||||
__max_try__ = 30
|
|
||||||
if self._auth_count > __max_try__:
|
|
||||||
SchedulerChain().messagehelper.put(title=f"用户认证失败",
|
|
||||||
message="用户认证失败次数过多,将不再尝试认证!",
|
|
||||||
role="system")
|
|
||||||
return
|
|
||||||
logger.info("用户未认证,正在尝试重新认证...")
|
|
||||||
status, msg = SitesHelper().check_user()
|
|
||||||
if status:
|
|
||||||
self._auth_count = 0
|
|
||||||
logger.info(f"{msg} 用户认证成功")
|
|
||||||
SchedulerChain().post_message(
|
|
||||||
Notification(
|
|
||||||
mtype=NotificationType.Manual,
|
|
||||||
title="MoviePilot用户认证成功",
|
|
||||||
text=f"使用站点:{msg}",
|
|
||||||
link=settings.MP_DOMAIN('#/site')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
PluginManager().init_config()
|
|
||||||
self.init_plugin_jobs()
|
|
||||||
|
|
||||||
else:
|
|
||||||
self._auth_count += 1
|
|
||||||
logger.error(f"用户认证失败:{msg},共失败 {self._auth_count} 次")
|
|
||||||
if self._auth_count >= __max_try__:
|
|
||||||
logger.error("用户认证失败次数过多,将不再尝试认证!")
|
|
||||||
|
|
||||||
# 各服务的运行状态
|
# 各服务的运行状态
|
||||||
self._jobs = {
|
self._jobs = {
|
||||||
"cookiecloud": {
|
"cookiecloud": {
|
||||||
@@ -141,12 +99,12 @@ class Scheduler(metaclass=Singleton):
|
|||||||
},
|
},
|
||||||
"clear_cache": {
|
"clear_cache": {
|
||||||
"name": "缓存清理",
|
"name": "缓存清理",
|
||||||
"func": clear_cache,
|
"func": self.clear_cache,
|
||||||
"running": False,
|
"running": False,
|
||||||
},
|
},
|
||||||
"user_auth": {
|
"user_auth": {
|
||||||
"name": "用户认证检查",
|
"name": "用户认证检查",
|
||||||
"func": user_auth,
|
"func": self.user_auth,
|
||||||
"running": False,
|
"running": False,
|
||||||
},
|
},
|
||||||
"scheduler_job": {
|
"scheduler_job": {
|
||||||
@@ -321,7 +279,7 @@ class Scheduler(metaclass=Singleton):
|
|||||||
"interval",
|
"interval",
|
||||||
id="clear_cache",
|
id="clear_cache",
|
||||||
name="缓存清理",
|
name="缓存清理",
|
||||||
hours=settings.CACHE_CONF.get("meta") / 3600,
|
hours=settings.CACHE_CONF["meta"] / 3600,
|
||||||
kwargs={
|
kwargs={
|
||||||
'job_id': 'clear_cache'
|
'job_id': 'clear_cache'
|
||||||
}
|
}
|
||||||
@@ -429,11 +387,13 @@ class Scheduler(metaclass=Singleton):
|
|||||||
try:
|
try:
|
||||||
sid = f"{service['id']}"
|
sid = f"{service['id']}"
|
||||||
job_id = sid.split("|")[0]
|
job_id = sid.split("|")[0]
|
||||||
|
self.remove_plugin_job(pid, job_id)
|
||||||
self._jobs[job_id] = {
|
self._jobs[job_id] = {
|
||||||
"func": service["func"],
|
"func": service["func"],
|
||||||
"name": service["name"],
|
"name": service["name"],
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
"plugin_name": plugin_name,
|
"plugin_name": plugin_name,
|
||||||
|
"kwargs": service.get("func_kwargs") or {},
|
||||||
"running": False,
|
"running": False,
|
||||||
}
|
}
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
@@ -441,7 +401,7 @@ class Scheduler(metaclass=Singleton):
|
|||||||
service["trigger"],
|
service["trigger"],
|
||||||
id=sid,
|
id=sid,
|
||||||
name=service["name"],
|
name=service["name"],
|
||||||
**service["kwargs"],
|
**(service.get("kwargs") or {}),
|
||||||
kwargs={"job_id": job_id},
|
kwargs={"job_id": job_id},
|
||||||
replace_existing=True
|
replace_existing=True
|
||||||
)
|
)
|
||||||
@@ -452,23 +412,34 @@ class Scheduler(metaclass=Singleton):
|
|||||||
message=str(e),
|
message=str(e),
|
||||||
role="system")
|
role="system")
|
||||||
|
|
||||||
def remove_plugin_job(self, pid: str):
|
def remove_plugin_job(self, pid: str, job_id: str = None):
|
||||||
"""
|
"""
|
||||||
移除插件定时服务
|
移除定时服务,可以是单个服务(包括默认服务)或整个插件的所有服务
|
||||||
|
:param pid: 插件 ID
|
||||||
|
:param job_id: 可选,指定要移除的单个服务的 job_id。如果不提供,则移除该插件的所有服务,当移除单个服务时,默认服务也包含在内
|
||||||
"""
|
"""
|
||||||
if not self._scheduler:
|
if not self._scheduler:
|
||||||
return
|
return
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# 获取插件名称
|
if job_id:
|
||||||
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
|
# 移除单个服务
|
||||||
# 先从 _jobs 中查找匹配的服务
|
service = self._jobs.pop(job_id, None)
|
||||||
jobs_to_remove = [(job_id, service) for job_id, service in self._jobs.items() if service.get("pid") == pid]
|
if not service:
|
||||||
|
return
|
||||||
|
jobs_to_remove = [(job_id, service)]
|
||||||
|
else:
|
||||||
|
# 移除插件的所有服务
|
||||||
|
jobs_to_remove = [
|
||||||
|
(job_id, service) for job_id, service in self._jobs.items() if service.get("pid") == pid
|
||||||
|
]
|
||||||
|
for job_id, _ in jobs_to_remove:
|
||||||
|
self._jobs.pop(job_id, None)
|
||||||
if not jobs_to_remove:
|
if not jobs_to_remove:
|
||||||
return
|
return
|
||||||
|
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
|
||||||
|
# 遍历移除任务
|
||||||
for job_id, service in jobs_to_remove:
|
for job_id, service in jobs_to_remove:
|
||||||
try:
|
try:
|
||||||
# 移除服务
|
|
||||||
self._jobs.pop(job_id, None)
|
|
||||||
# 在调度器中查找并移除对应的 job
|
# 在调度器中查找并移除对应的 job
|
||||||
job_removed = False
|
job_removed = False
|
||||||
for job in list(self._scheduler.get_jobs()):
|
for job in list(self._scheduler.get_jobs()):
|
||||||
@@ -552,3 +523,50 @@ class Scheduler(metaclass=Singleton):
|
|||||||
logger.info("定时任务停止完成")
|
logger.info("定时任务停止完成")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"停止定时任务失败::{str(e)} - {traceback.format_exc()}")
|
logger.error(f"停止定时任务失败::{str(e)} - {traceback.format_exc()}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clear_cache():
|
||||||
|
"""
|
||||||
|
清理缓存
|
||||||
|
"""
|
||||||
|
TorrentsChain().clear_cache()
|
||||||
|
SchedulerChain().clear_cache()
|
||||||
|
|
||||||
|
def user_auth(self):
|
||||||
|
"""
|
||||||
|
用户认证检查
|
||||||
|
"""
|
||||||
|
if SitesHelper().auth_level >= 2:
|
||||||
|
return
|
||||||
|
# 最大重试次数
|
||||||
|
__max_try__ = 30
|
||||||
|
if self._auth_count > __max_try__:
|
||||||
|
SchedulerChain().messagehelper.put(title=f"用户认证失败",
|
||||||
|
message="用户认证失败次数过多,将不再尝试认证!",
|
||||||
|
role="system")
|
||||||
|
return
|
||||||
|
logger.info("用户未认证,正在尝试认证...")
|
||||||
|
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
|
||||||
|
if auth_conf:
|
||||||
|
status, msg = SitesHelper().check_user(**auth_conf)
|
||||||
|
else:
|
||||||
|
status, msg = SitesHelper().check_user()
|
||||||
|
if status:
|
||||||
|
self._auth_count = 0
|
||||||
|
logger.info(f"{msg} 用户认证成功")
|
||||||
|
SchedulerChain().post_message(
|
||||||
|
Notification(
|
||||||
|
mtype=NotificationType.Manual,
|
||||||
|
title="MoviePilot用户认证成功",
|
||||||
|
text=f"使用站点:{msg}",
|
||||||
|
link=settings.MP_DOMAIN('#/site')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
PluginManager().init_config()
|
||||||
|
self.init_plugin_jobs()
|
||||||
|
|
||||||
|
else:
|
||||||
|
self._auth_count += 1
|
||||||
|
logger.error(f"用户认证失败:{msg},共失败 {self._auth_count} 次")
|
||||||
|
if self._auth_count >= __max_try__:
|
||||||
|
logger.error("用户认证失败次数过多,将不再尝试认证!")
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ class TorrentInfo(BaseModel):
|
|||||||
site_proxy: Optional[bool] = False
|
site_proxy: Optional[bool] = False
|
||||||
# 站点优先级
|
# 站点优先级
|
||||||
site_order: Optional[int] = 0
|
site_order: Optional[int] = 0
|
||||||
|
# 站点下载器
|
||||||
|
site_downloader: Optional[str] = None
|
||||||
# 种子名称
|
# 种子名称
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
# 种子副标题
|
# 种子副标题
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Optional, Dict
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, root_validator
|
from pydantic import BaseModel, Field, root_validator
|
||||||
|
|
||||||
@@ -114,3 +115,31 @@ class CommandRegisterEventData(ChainEventData):
|
|||||||
# 输出参数
|
# 输出参数
|
||||||
cancel: bool = Field(False, description="是否取消注册")
|
cancel: bool = Field(False, description="是否取消注册")
|
||||||
source: str = Field("未知拦截源", description="拦截源")
|
source: str = Field("未知拦截源", description="拦截源")
|
||||||
|
|
||||||
|
|
||||||
|
class TransferRenameEventData(ChainEventData):
|
||||||
|
"""
|
||||||
|
TransferRename 事件的数据模型
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
# 输入参数
|
||||||
|
template_string (str): Jinja2 模板字符串
|
||||||
|
rename_dict (dict): 渲染上下文
|
||||||
|
render_str (str): 渲染生成的字符串
|
||||||
|
path (Optional[Path]): 当前文件的目标路径
|
||||||
|
|
||||||
|
# 输出参数
|
||||||
|
updated (bool): 是否已更新,默认值为 False
|
||||||
|
updated_str (str): 更新后的字符串
|
||||||
|
source (str): 拦截源,默认值为 "未知拦截源"
|
||||||
|
"""
|
||||||
|
# 输入参数
|
||||||
|
template_string: str = Field(..., description="模板字符串")
|
||||||
|
rename_dict: Dict[str, Any] = Field(..., description="渲染上下文")
|
||||||
|
path: Optional[Path] = Field(None, description="文件的目标路径")
|
||||||
|
render_str: str = Field(..., description="渲染生成的字符串")
|
||||||
|
|
||||||
|
# 输出参数
|
||||||
|
updated: bool = Field(False, description="是否已更新")
|
||||||
|
updated_str: Optional[str] = Field(None, description="更新后的字符串")
|
||||||
|
source: Optional[str] = Field("未知拦截源", description="拦截源")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Optional, Any, Union
|
from typing import Optional, Any, Union, Dict
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ class Site(BaseModel):
|
|||||||
# 备注
|
# 备注
|
||||||
note: Optional[Any] = None
|
note: Optional[Any] = None
|
||||||
# 超时时间
|
# 超时时间
|
||||||
timeout: Optional[int] = 0
|
timeout: Optional[int] = 15
|
||||||
# 流控单位周期
|
# 流控单位周期
|
||||||
limit_interval: Optional[int] = None
|
limit_interval: Optional[int] = None
|
||||||
# 流控次数
|
# 流控次数
|
||||||
@@ -44,6 +44,8 @@ class Site(BaseModel):
|
|||||||
limit_seconds: Optional[int] = None
|
limit_seconds: Optional[int] = None
|
||||||
# 是否启用
|
# 是否启用
|
||||||
is_active: Optional[bool] = True
|
is_active: Optional[bool] = True
|
||||||
|
# 下载器
|
||||||
|
downloader: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
@@ -108,3 +110,8 @@ class SiteUserData(BaseModel):
|
|||||||
updated_day: Optional[str] = None
|
updated_day: Optional[str] = None
|
||||||
# 更新时间
|
# 更新时间
|
||||||
updated_time: Optional[str] = None
|
updated_time: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SiteAuth(BaseModel):
|
||||||
|
site: Optional[str] = None
|
||||||
|
params: Optional[Dict[str, Union[int, str]]] = {}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ class Subscribe(BaseModel):
|
|||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
# 订阅站点
|
# 订阅站点
|
||||||
sites: Optional[List[int]] = []
|
sites: Optional[List[int]] = []
|
||||||
|
# 下载器
|
||||||
|
downloader: Optional[str] = None
|
||||||
# 是否洗版
|
# 是否洗版
|
||||||
best_version: Optional[int] = 0
|
best_version: Optional[int] = 0
|
||||||
# 当前优先级
|
# 当前优先级
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class TransferTorrent(BaseModel):
|
|||||||
"""
|
"""
|
||||||
待转移任务信息
|
待转移任务信息
|
||||||
"""
|
"""
|
||||||
|
downloader: Optional[str] = None
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
path: Optional[Path] = None
|
path: Optional[Path] = None
|
||||||
hash: Optional[str] = None
|
hash: Optional[str] = None
|
||||||
@@ -22,6 +23,7 @@ class DownloadingTorrent(BaseModel):
|
|||||||
"""
|
"""
|
||||||
下载中任务信息
|
下载中任务信息
|
||||||
"""
|
"""
|
||||||
|
downloader: Optional[str] = None
|
||||||
hash: Optional[str] = None
|
hash: Optional[str] = None
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
|||||||
@@ -60,14 +60,16 @@ class EventType(Enum):
|
|||||||
|
|
||||||
# 同步链式事件
|
# 同步链式事件
|
||||||
class ChainEventType(Enum):
|
class ChainEventType(Enum):
|
||||||
# 名称识别请求
|
# 名称识别
|
||||||
NameRecognize = "name.recognize"
|
NameRecognize = "name.recognize"
|
||||||
# 认证验证请求
|
# 认证验证
|
||||||
AuthVerification = "auth.verification"
|
AuthVerification = "auth.verification"
|
||||||
# 认证拦截请求
|
# 认证拦截
|
||||||
AuthIntercept = "auth.intercept"
|
AuthIntercept = "auth.intercept"
|
||||||
# 命令注册请求
|
# 命令注册
|
||||||
CommandRegister = "command.register"
|
CommandRegister = "command.register"
|
||||||
|
# 整理重命名
|
||||||
|
TransferRename = "transfer.rename"
|
||||||
|
|
||||||
|
|
||||||
# 系统配置Key字典
|
# 系统配置Key字典
|
||||||
@@ -122,6 +124,8 @@ class SystemConfigKey(Enum):
|
|||||||
DefaultMovieSubscribeConfig = "DefaultMovieSubscribeConfig"
|
DefaultMovieSubscribeConfig = "DefaultMovieSubscribeConfig"
|
||||||
# 默认电视剧订阅规则
|
# 默认电视剧订阅规则
|
||||||
DefaultTvSubscribeConfig = "DefaultTvSubscribeConfig"
|
DefaultTvSubscribeConfig = "DefaultTvSubscribeConfig"
|
||||||
|
# 用户站点认证参数
|
||||||
|
UserSiteAuthParams = "UserSiteAuthParams"
|
||||||
|
|
||||||
|
|
||||||
# 处理进度Key字典
|
# 处理进度Key字典
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ from app.helper.message import MessageHelper
|
|||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.monitor import Monitor
|
from app.monitor import Monitor
|
||||||
from app.schemas import Notification, NotificationType
|
from app.schemas import Notification, NotificationType
|
||||||
|
from app.schemas.types import SystemConfigKey
|
||||||
from app.db import close_database
|
from app.db import close_database
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.chain.command import CommandChain
|
from app.chain.command import CommandChain
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +74,19 @@ def clear_temp():
|
|||||||
SystemUtils.clear(settings.CACHE_PATH / "images", days=7)
|
SystemUtils.clear(settings.CACHE_PATH / "images", days=7)
|
||||||
|
|
||||||
|
|
||||||
|
def user_auth():
|
||||||
|
"""
|
||||||
|
用户认证检查
|
||||||
|
"""
|
||||||
|
if SitesHelper().auth_level >= 2:
|
||||||
|
return
|
||||||
|
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
|
||||||
|
if auth_conf:
|
||||||
|
SitesHelper().check_user(**auth_conf)
|
||||||
|
else:
|
||||||
|
SitesHelper().check_user()
|
||||||
|
|
||||||
|
|
||||||
def check_auth():
|
def check_auth():
|
||||||
"""
|
"""
|
||||||
检查认证状态
|
检查认证状态
|
||||||
@@ -128,6 +143,8 @@ def start_modules(_: FastAPI):
|
|||||||
SitesHelper()
|
SitesHelper()
|
||||||
# 资源包检测
|
# 资源包检测
|
||||||
ResourceHelper()
|
ResourceHelper()
|
||||||
|
# 用户认证
|
||||||
|
user_auth()
|
||||||
# 加载模块
|
# 加载模块
|
||||||
ModuleManager()
|
ModuleManager()
|
||||||
# 启动事件消费
|
# 启动事件消费
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import re
|
||||||
from typing import Any, Optional, Union
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
import chardet
|
||||||
import requests
|
import requests
|
||||||
import urllib3
|
import urllib3
|
||||||
from requests import Response, Session
|
from requests import Response, Session
|
||||||
@@ -273,3 +275,108 @@ class RequestUtils:
|
|||||||
cache_headers["Cache-Control"] = f"max-age={max_age}"
|
cache_headers["Cache-Control"] = f"max-age={max_age}"
|
||||||
|
|
||||||
return cache_headers
|
return cache_headers
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def detect_encoding_from_html_response(response: Response,
|
||||||
|
performance_mode: bool = False, confidence_threshold: float = 0.8):
|
||||||
|
"""
|
||||||
|
根据HTML响应内容探测编码信息
|
||||||
|
|
||||||
|
:param response: HTTP 响应对象
|
||||||
|
:param performance_mode: 是否使用性能模式,默认为 False (兼容模式)
|
||||||
|
:param confidence_threshold: chardet 检测置信度阈值,默认为 0.8
|
||||||
|
:return: 解析得到的字符编码
|
||||||
|
"""
|
||||||
|
fallback_encoding = None
|
||||||
|
try:
|
||||||
|
if not performance_mode:
|
||||||
|
# 兼容模式:使用chardet分析后,再处理 BOM 和 meta 信息
|
||||||
|
# 1. 使用 chardet 库进一步分析内容
|
||||||
|
detection = chardet.detect(response.content)
|
||||||
|
if detection["confidence"] > confidence_threshold:
|
||||||
|
return detection.get("encoding")
|
||||||
|
# 保存 chardet 的结果备用
|
||||||
|
fallback_encoding = detection.get("encoding")
|
||||||
|
|
||||||
|
# 2. 检查响应体中的 BOM 标记(例如 UTF-8 BOM)
|
||||||
|
if response.content[:3] == b"\xef\xbb\xbf": # UTF-8 BOM
|
||||||
|
return "utf-8"
|
||||||
|
|
||||||
|
# 3. 如果是 HTML 响应体,检查其中的 <meta charset="..."> 标签
|
||||||
|
if re.search(r"charset=[\"']?utf-8[\"']?", response.text, re.IGNORECASE):
|
||||||
|
return "utf-8"
|
||||||
|
|
||||||
|
# 4. 尝试从 response headers 中获取编码信息
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
if re.search(r"charset=[\"']?utf-8[\"']?", content_type, re.IGNORECASE):
|
||||||
|
return "utf-8"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 性能模式:优先从 headers 和 BOM 标记获取,最后使用 chardet 分析
|
||||||
|
# 1. 尝试从 response headers 中获取编码信息
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
if re.search(r"charset=[\"']?utf-8[\"']?", content_type, re.IGNORECASE):
|
||||||
|
return "utf-8"
|
||||||
|
# 暂不支持直接提取字符集,仅提取UTF8
|
||||||
|
# match = re.search(r"charset=[\"']?([^\"';\s]+)", content_type, re.IGNORECASE)
|
||||||
|
# if match:
|
||||||
|
# return match.group(1)
|
||||||
|
|
||||||
|
# 2. 检查响应体中的 BOM 标记(例如 UTF-8 BOM)
|
||||||
|
if response.content[:3] == b"\xef\xbb\xbf":
|
||||||
|
return "utf-8"
|
||||||
|
|
||||||
|
# 3. 如果是 HTML 响应体,检查其中的 <meta charset="..."> 标签
|
||||||
|
if re.search(r"charset=[\"']?utf-8[\"']?", response.text, re.IGNORECASE):
|
||||||
|
return "utf-8"
|
||||||
|
# 暂不支持直接提取字符集,仅提取UTF8
|
||||||
|
# match = re.search(r"<meta[^>]+charset=[\"']?([^\"'>\s]+)", response.text, re.IGNORECASE)
|
||||||
|
# if match:
|
||||||
|
# return match.group(1)
|
||||||
|
|
||||||
|
# 4. 使用 chardet 库进一步分析内容
|
||||||
|
detection = chardet.detect(response.content)
|
||||||
|
if detection.get("confidence", 0) > confidence_threshold:
|
||||||
|
return detection.get("encoding")
|
||||||
|
# 保存 chardet 的结果备用
|
||||||
|
fallback_encoding = detection.get("encoding")
|
||||||
|
|
||||||
|
# 5. 如果上述方法都无法确定,信任 chardet 的结果(即使置信度较低),否则返回默认字符集
|
||||||
|
return fallback_encoding or "utf-8"
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error when detect_encoding_from_response: {str(e)}")
|
||||||
|
return fallback_encoding or "utf-8"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_decoded_html_content(response: Response,
|
||||||
|
performance_mode: bool = False, confidence_threshold: float = 0.8) -> str:
|
||||||
|
"""
|
||||||
|
获取HTML响应的解码文本内容
|
||||||
|
|
||||||
|
:param response: HTTP 响应对象
|
||||||
|
:param performance_mode: 是否使用性能模式,默认为 False (兼容模式)
|
||||||
|
:param confidence_threshold: chardet 检测置信度阈值,默认为 0.8
|
||||||
|
:return: 解码后的响应文本内容
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not response:
|
||||||
|
return ""
|
||||||
|
if response.content:
|
||||||
|
# 1. 获取编码信息
|
||||||
|
encoding = (RequestUtils.detect_encoding_from_html_response(response, performance_mode,
|
||||||
|
confidence_threshold)
|
||||||
|
or response.apparent_encoding)
|
||||||
|
# 2. 根据解析得到的编码进行解码
|
||||||
|
try:
|
||||||
|
# 尝试用推测的编码解码
|
||||||
|
return response.content.decode(encoding)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Decoding failed, error message: {str(e)}")
|
||||||
|
# 如果解码失败,尝试 fallback 使用 apparent_encoding
|
||||||
|
response.encoding = response.apparent_encoding
|
||||||
|
return response.text
|
||||||
|
else:
|
||||||
|
return response.text
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error when getting decoded content: {str(e)}")
|
||||||
|
return response.text
|
||||||
|
|||||||
@@ -275,6 +275,10 @@ class SystemUtils:
|
|||||||
# 遍历目录
|
# 遍历目录
|
||||||
for path in directory.iterdir():
|
for path in directory.iterdir():
|
||||||
if path.is_dir():
|
if path.is_dir():
|
||||||
|
if not SystemUtils.is_windows() and path.name.startswith("."):
|
||||||
|
continue
|
||||||
|
if path.name == "@eaDir":
|
||||||
|
continue
|
||||||
dirs.append(path)
|
dirs.append(path)
|
||||||
|
|
||||||
return dirs
|
return dirs
|
||||||
|
|||||||
31
database/versions/eaf9cbc49027_2_0_7.py
Normal file
31
database/versions/eaf9cbc49027_2_0_7.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""2.0.7
|
||||||
|
|
||||||
|
Revision ID: eaf9cbc49027
|
||||||
|
Revises: a295e41830a6
|
||||||
|
Create Date: 2024-11-16 00:26:09.505188
|
||||||
|
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'eaf9cbc49027'
|
||||||
|
down_revision = 'a295e41830a6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
# 站点管理、订阅增加下载器选项
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
op.add_column('site', sa.Column('downloader', sa.String(), nullable=True))
|
||||||
|
op.add_column('subscribe', sa.Column('downloader', sa.String(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
# shellcheck disable=SC2016
|
# shellcheck disable=SC2016
|
||||||
|
|
||||||
# 使用 `envsubst` 将模板文件中的 ${NGINX_PORT} 替换为实际的环境变量值
|
# 使用 `envsubst` 将模板文件中的 ${NGINX_PORT} 替换为实际的环境变量值
|
||||||
envsubst '${NGINX_PORT}${PORT}' < /etc/nginx/nginx.template.conf > /etc/nginx/nginx.conf
|
export NGINX_CLIENT_MAX_BODY_SIZE=${NGINX_CLIENT_MAX_BODY_SIZE:-10m}
|
||||||
|
envsubst '${NGINX_PORT}${PORT}${NGINX_CLIENT_MAX_BODY_SIZE}' < /etc/nginx/nginx.template.conf > /etc/nginx/nginx.conf
|
||||||
# 自动更新
|
# 自动更新
|
||||||
cd /
|
cd /
|
||||||
/usr/local/bin/mp_update
|
/usr/local/bin/mp_update
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ http {
|
|||||||
|
|
||||||
keepalive_timeout 3600;
|
keepalive_timeout 3600;
|
||||||
|
|
||||||
|
client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE};
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
gzip_proxied any;
|
gzip_proxied any;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ APScheduler~=3.10.1
|
|||||||
cryptography~=43.0.0
|
cryptography~=43.0.0
|
||||||
pytz~=2023.3
|
pytz~=2023.3
|
||||||
pycryptodome~=3.20.0
|
pycryptodome~=3.20.0
|
||||||
qbittorrent-api==2024.9.67
|
qbittorrent-api==2024.11.69
|
||||||
plexapi~=4.15.16
|
plexapi~=4.15.16
|
||||||
transmission-rpc~=4.3.0
|
transmission-rpc~=4.3.0
|
||||||
Jinja2~=3.1.4
|
Jinja2~=3.1.4
|
||||||
@@ -58,7 +58,6 @@ pystray~=0.19.5
|
|||||||
pyotp~=2.9.0
|
pyotp~=2.9.0
|
||||||
Pinyin2Hanzi~=0.1.1
|
Pinyin2Hanzi~=0.1.1
|
||||||
pywebpush~=2.0.0
|
pywebpush~=2.0.0
|
||||||
py115j~=0.0.7
|
python-115~=0.0.9.8.7
|
||||||
oss2~=2.18.6
|
|
||||||
aligo~=6.2.4
|
aligo~=6.2.4
|
||||||
aiofiles~=24.1.0
|
aiofiles~=24.1.0
|
||||||
@@ -345,13 +345,13 @@ meta_cases = [{
|
|||||||
"part": "",
|
"part": "",
|
||||||
"season": "",
|
"season": "",
|
||||||
"episode": "",
|
"episode": "",
|
||||||
"restype": "BluRay Remux",
|
"restype": "BluRay REMUX",
|
||||||
"pix": "1080p",
|
"pix": "1080p",
|
||||||
"video_codec": "AVC",
|
"video_codec": "AVC",
|
||||||
"audio_codec": "LPCM 7³"
|
"audio_codec": "LPCM 7³"
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"title": "30.Rock.S02E01.1080p.BluRay.X264-BORDURE.mkv",
|
"title": "30.Rock.S02E01.1080p.UHD.BluRay.X264-BORDURE.mkv",
|
||||||
"subtitle": "",
|
"subtitle": "",
|
||||||
"target": {
|
"target": {
|
||||||
"type": "电视剧",
|
"type": "电视剧",
|
||||||
@@ -361,7 +361,7 @@ meta_cases = [{
|
|||||||
"part": "",
|
"part": "",
|
||||||
"season": "S02",
|
"season": "S02",
|
||||||
"episode": "E01",
|
"episode": "E01",
|
||||||
"restype": "BluRay",
|
"restype": "UHD BluRay",
|
||||||
"pix": "1080p",
|
"pix": "1080p",
|
||||||
"video_codec": "X264",
|
"video_codec": "X264",
|
||||||
"audio_codec": ""
|
"audio_codec": ""
|
||||||
@@ -611,7 +611,7 @@ meta_cases = [{
|
|||||||
"subtitle": "",
|
"subtitle": "",
|
||||||
"target": {
|
"target": {
|
||||||
"type": "电视剧",
|
"type": "电视剧",
|
||||||
"cn_name": "处刑少女的生存之道",
|
"cn_name": "處刑少女的生存之道",
|
||||||
"en_name": "",
|
"en_name": "",
|
||||||
"year": "",
|
"year": "",
|
||||||
"part": "",
|
"part": "",
|
||||||
@@ -665,7 +665,7 @@ meta_cases = [{
|
|||||||
"part": "",
|
"part": "",
|
||||||
"season": "",
|
"season": "",
|
||||||
"episode": "",
|
"episode": "",
|
||||||
"restype": "BluRay DoVi UHD",
|
"restype": "UHD BluRay DoVi",
|
||||||
"pix": "1080p",
|
"pix": "1080p",
|
||||||
"video_codec": "X265",
|
"video_codec": "X265",
|
||||||
"audio_codec": "DD 7.1"
|
"audio_codec": "DD 7.1"
|
||||||
|
|||||||
61
update
61
update
@@ -28,9 +28,9 @@ function download_and_unzip() {
|
|||||||
local target_dir="$2"
|
local target_dir="$2"
|
||||||
INFO "正在下载 ${url}..."
|
INFO "正在下载 ${url}..."
|
||||||
while [ $retries -lt $max_retries ]; do
|
while [ $retries -lt $max_retries ]; do
|
||||||
if curl ${CURL_OPTIONS} "${url}" ${CURL_HEADERS} | busybox unzip -d /tmp - > /dev/null; then
|
if curl ${CURL_OPTIONS} "${url}" ${CURL_HEADERS} | busybox unzip -d ${TMP_PATH} - > /dev/null; then
|
||||||
if [ -e /tmp/MoviePilot-* ]; then
|
if [ -e ${TMP_PATH}/MoviePilot-* ]; then
|
||||||
mv /tmp/MoviePilot-* /tmp/"${target_dir}"
|
mv ${TMP_PATH}/MoviePilot-* ${TMP_PATH}/"${target_dir}"
|
||||||
fi
|
fi
|
||||||
break
|
break
|
||||||
else
|
else
|
||||||
@@ -48,8 +48,6 @@ function download_and_unzip() {
|
|||||||
|
|
||||||
# 下载程序资源,$1: 后端版本路径
|
# 下载程序资源,$1: 后端版本路径
|
||||||
function install_backend_and_download_resources() {
|
function install_backend_and_download_resources() {
|
||||||
# 清理临时目录,上次安装失败可能有残留
|
|
||||||
rm -rf /tmp/*
|
|
||||||
# 更新后端程序
|
# 更新后端程序
|
||||||
if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then
|
if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then
|
||||||
WARN "后端程序下载失败,继续使用旧的程序来启动..."
|
WARN "后端程序下载失败,继续使用旧的程序来启动..."
|
||||||
@@ -61,16 +59,33 @@ function install_backend_and_download_resources() {
|
|||||||
ERROR "pip 更新失败,请重新拉取镜像"
|
ERROR "pip 更新失败,请重新拉取镜像"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if ! pip install ${PIP_OPTIONS} --root-user-action=ignore -r /tmp/App/requirements.txt > /dev/null; then
|
if ! pip install ${PIP_OPTIONS} --root-user-action=ignore -r ${TMP_PATH}/App/requirements.txt > /dev/null; then
|
||||||
ERROR "安装依赖失败,请重新拉取镜像"
|
ERROR "安装依赖失败,请重新拉取镜像"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
INFO "安装依赖成功"
|
INFO "安装依赖成功"
|
||||||
# 从后端文件中读取前端版本号
|
# 如果是"heads/v2.zip",则查找v2开头的最新版本号
|
||||||
frontend_version=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" /tmp/App/version.py)
|
if [[ "${1}" == "heads/v2.zip" ]]; then
|
||||||
if [[ "${frontend_version}" != *v* ]]; then
|
INFO "正在获取前端最新版本号..."
|
||||||
WARN "前端最新版本号获取失败,继续启动..."
|
# 获取所有发布的版本列表,并筛选出以v2开头的版本号
|
||||||
return 1
|
releases=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep "^v2\.")
|
||||||
|
if [ -z "$releases" ]; then
|
||||||
|
WARN "未找到任何v2前端版本,继续启动..."
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
# 找到最新的v2版本
|
||||||
|
frontend_version=$(echo "$releases" | sort -V | tail -n 1)
|
||||||
|
fi
|
||||||
|
INFO "前端最新版本号:${frontend_version}"
|
||||||
|
else
|
||||||
|
INFO "正在获取前端版本号..."
|
||||||
|
# 从后端文件中读取前端版本号
|
||||||
|
frontend_version=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" ${TMP_PATH}/App/version.py)
|
||||||
|
if [[ "${frontend_version}" != *v* ]]; then
|
||||||
|
WARN "前端版本号获取失败,继续启动..."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
INFO "前端版本号:${frontend_version}"
|
||||||
fi
|
fi
|
||||||
# 更新前端程序
|
# 更新前端程序
|
||||||
if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then
|
if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then
|
||||||
@@ -94,11 +109,11 @@ function install_backend_and_download_resources() {
|
|||||||
rm -rf /app
|
rm -rf /app
|
||||||
mkdir -p /app
|
mkdir -p /app
|
||||||
# 复制新后端程序
|
# 复制新后端程序
|
||||||
cp -a /tmp/App/* /app/
|
cp -a ${TMP_PATH}/App/* /app/
|
||||||
# 复制新前端程序
|
# 复制新前端程序
|
||||||
rm -rf /public
|
rm -rf /public
|
||||||
mkdir -p /public
|
mkdir -p /public
|
||||||
cp -a /tmp/dist/* /public/
|
cp -a ${TMP_PATH}/dist/* /public/
|
||||||
INFO "程序部分更新成功,前端版本:${frontend_version},后端版本:${1}"
|
INFO "程序部分更新成功,前端版本:${frontend_version},后端版本:${1}"
|
||||||
# 恢复插件目录
|
# 恢复插件目录
|
||||||
cp -a /plugins/* /app/app/plugins/
|
cp -a /plugins/* /app/app/plugins/
|
||||||
@@ -112,10 +127,10 @@ function install_backend_and_download_resources() {
|
|||||||
fi
|
fi
|
||||||
INFO "站点资源下载成功"
|
INFO "站点资源下载成功"
|
||||||
# 复制新站点资源
|
# 复制新站点资源
|
||||||
cp -a /tmp/Resources/resources/* /app/app/helper/
|
cp -a ${TMP_PATH}/Resources/resources/* /app/app/helper/
|
||||||
INFO "站点资源更新成功"
|
INFO "站点资源更新成功"
|
||||||
# 清理临时目录
|
# 清理临时目录
|
||||||
rm -rf /tmp/*
|
rm -rf "${TMP_PATH}"
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +265,15 @@ function get_priority() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" = "release" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]]; then
|
if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" = "release" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]]; then
|
||||||
|
TMP_PATH=$(mktemp -d)
|
||||||
|
if [ ! -d "${TMP_PATH}" ]; then
|
||||||
|
# 如果自动生成 tmp 文件夹失败则手动指定,避免出现数据丢失等情况
|
||||||
|
TMP_PATH=/tmp/mp_update_path
|
||||||
|
if [ -d /tmp/mp_update_path ]; then
|
||||||
|
rm -rf /tmp/mp_update_path
|
||||||
|
fi
|
||||||
|
mkdir -p /tmp/mp_update_path
|
||||||
|
fi
|
||||||
# 优先级:镜像站 > 全局 > 不代理
|
# 优先级:镜像站 > 全局 > 不代理
|
||||||
# pip
|
# pip
|
||||||
retries=0
|
retries=0
|
||||||
@@ -287,11 +311,11 @@ if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}"
|
|||||||
# 获取所有发布的版本列表,并筛选出以v2开头的版本号
|
# 获取所有发布的版本列表,并筛选出以v2开头的版本号
|
||||||
releases=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot/releases" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep "^v2\.")
|
releases=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot/releases" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep "^v2\.")
|
||||||
if [ -z "$releases" ]; then
|
if [ -z "$releases" ]; then
|
||||||
WARN "未找到任何v2.x版本,继续启动..."
|
WARN "未找到任何v2后端版本,继续启动..."
|
||||||
else
|
else
|
||||||
# 找到最新的v2版本
|
# 找到最新的v2版本
|
||||||
latest_v2=$(echo "$releases" | sort -V | tail -n 1)
|
latest_v2=$(echo "$releases" | sort -V | tail -n 1)
|
||||||
INFO "最新的v2.x版本号:${latest_v2}"
|
INFO "最新的v2后端版本号:${latest_v2}"
|
||||||
# 使用版本号比较函数进行比较,并下载最新版本
|
# 使用版本号比较函数进行比较,并下载最新版本
|
||||||
compare_versions "${current_version}" "${latest_v2}"
|
compare_versions "${current_version}" "${latest_v2}"
|
||||||
fi
|
fi
|
||||||
@@ -299,6 +323,9 @@ if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}"
|
|||||||
WARN "当前版本号获取失败,继续启动..."
|
WARN "当前版本号获取失败,继续启动..."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
if [ -d "${TMP_PATH}" ]; then
|
||||||
|
rm -rf "${TMP_PATH}"
|
||||||
|
fi
|
||||||
elif [[ "${MOVIEPILOT_AUTO_UPDATE}" = "false" ]]; then
|
elif [[ "${MOVIEPILOT_AUTO_UPDATE}" = "false" ]]; then
|
||||||
INFO "程序自动升级已关闭,如需自动升级请在创建容器时设置环境变量:MOVIEPILOT_AUTO_UPDATE=release"
|
INFO "程序自动升级已关闭,如需自动升级请在创建容器时设置环境变量:MOVIEPILOT_AUTO_UPDATE=release"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = 'v2.0.4'
|
APP_VERSION = 'v2.1.1'
|
||||||
FRONTEND_VERSION = 'v2.0.4'
|
FRONTEND_VERSION = 'v2.1.1'
|
||||||
|
|||||||
Reference in New Issue
Block a user