diff --git a/app/api/apiv1.py b/app/api/apiv1.py index c0c449c8..1dce06d1 100644 --- a/app/api/apiv1.py +++ b/app/api/apiv1.py @@ -2,7 +2,7 @@ from fastapi import APIRouter from app.api.endpoints import login, user, site, message, webhook, subscribe, \ media, douban, search, plugin, tmdb, history, system, download, dashboard, \ - filebrowser, transfer, mediaserver, bangumi + local, transfer, mediaserver, bangumi, aliyun, u115 api_router = APIRouter() api_router.include_router(login.router, prefix="/login", tags=["login"]) @@ -20,8 +20,9 @@ api_router.include_router(system.router, prefix="/system", tags=["system"]) api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"]) api_router.include_router(download.router, prefix="/download", tags=["download"]) api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) -api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"]) +api_router.include_router(local.router, prefix="/local", tags=["local"]) api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"]) api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"]) api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"]) - +api_router.include_router(aliyun.router, prefix="/aliyun", tags=["aliyun"]) +api_router.include_router(u115.router, prefix="/u115", tags=["115"]) diff --git a/app/api/endpoints/aliyun.py b/app/api/endpoints/aliyun.py new file mode 100644 index 00000000..8acc8cee --- /dev/null +++ b/app/api/endpoints/aliyun.py @@ -0,0 +1,221 @@ +from pathlib import Path +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException +from starlette.responses import Response + +from app import schemas +from app.chain.transfer import TransferChain +from app.core.config import settings +from app.core.metainfo import MetaInfoPath +from app.core.security import verify_token, verify_uri_token +from app.helper.aliyun import AliyunHelper +from app.helper.progress import ProgressHelper +from app.schemas.types import ProgressKey +from app.utils.string import StringUtils + +router = APIRouter() + + +@router.get("/qrcode", summary="生成二维码内容", response_model=schemas.Response) +def qrcode(_: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 生成二维码 + """ + qrcode_data, errmsg = AliyunHelper().generate_qrcode() + if qrcode_data: + return schemas.Response(success=True, data=qrcode_data) + return schemas.Response(success=False, message=errmsg) + + +@router.get("/check", summary="二维码登录确认", response_model=schemas.Response) +def check(ck: str, t: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 二维码登录确认 + """ + if not ck or not t: + return schemas.Response(success=False, message="参数错误") + data, errmsg = AliyunHelper().check_login(ck, t) + if data: + return schemas.Response(success=True, data=data) + return schemas.Response(success=False, message=errmsg) + + +@router.get("/userinfo", summary="查询用户信息", response_model=schemas.Response) +def userinfo(_: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 查询用户信息 + """ + aliyunhelper = AliyunHelper() + # 浏览一次文件确定token正确性 + aliyunhelper.list_files() + # 查询用户信息返回 + info = aliyunhelper.get_user_info() + if info: + return schemas.Response(success=True, data=info) + return schemas.Response(success=False) + + +@router.get("/list", summary="所有目录和文件(阿里云盘)", response_model=List[schemas.FileItem]) +def list_aliyun(path: str, + fileid: str, + filetype: str = "dir", + sort: str = 'updated_at', + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 查询当前目录下所有目录和文件 + :param path: 当前路径 + :param fileid: 文件ID + :param filetype: 文件类型 + :param sort: 排序方式,name:按名称排序,time:按修改时间排序 + :param _: token + :return: 所有目录和文件 + """ + if not fileid: + return [] + if not path: + path = "/" + if sort == "time": + sort = "updated_at" + if filetype == "file": + fileinfo = AliyunHelper().get_file_detail(fileid) + if fileinfo: + return [schemas.FileItem( + fileid=fileinfo.get("file_id"), + parent_fileid=fileinfo.get("parent_file_id"), + type="file", + path=f"{path}{fileinfo.get('name')}", + name=fileinfo.get("name"), + size=fileinfo.get("size"), + extension=fileinfo.get("file_extension"), + modify_time=StringUtils.str_to_timestamp(fileinfo.get("updated_at")), + thumbnail=fileinfo.get("thumbnail") + )] + return [] + items = AliyunHelper().list_files(parent_file_id=fileid, order_by=sort) + if not items: + return [] + return [schemas.FileItem( + fileid=item.get("file_id"), + parent_fileid=item.get("parent_file_id"), + type="dir" if item.get("type") == "folder" else "file", + path=f"{path}{item.get('name')}" + "/" if item.get("type") == "folder" else "", + name=item.get("name"), + size=item.get("size"), + extension=item.get("file_extension"), + modify_time=StringUtils.str_to_timestamp(item.get("updated_at")), + thumbnail=item.get("thumbnail") + ) for item in items] + + +@router.get("/mkdir", summary="创建目录(阿里云盘)", response_model=schemas.Response) +def mkdir_aliyun(fileid: str, + name: str, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 创建目录 + """ + if not fileid or not name: + return schemas.Response(success=False) + result = AliyunHelper().create_folder(parent_file_id=fileid, name=name) + if result: + return schemas.Response(success=True) + return schemas.Response(success=False) + + +@router.get("/delete", summary="删除文件或目录(阿里云盘)", response_model=schemas.Response) +def delete_aliyun(fileid: str, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 删除文件或目录 + """ + if not fileid: + return schemas.Response(success=False) + result = AliyunHelper().delete_file(fileid) + if result: + return schemas.Response(success=True) + return schemas.Response(success=False) + + +@router.get("/download", summary="下载文件(阿里云盘)") +def download_aliyun(fileid: str, + _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any: + """ + 下载文件或目录 + """ + if not fileid: + return schemas.Response(success=False) + url = AliyunHelper().get_download_url(fileid) + if url: + # 重定向 + return Response(status_code=302, headers={"Location": url}) + raise HTTPException(status_code=500, detail="下载文件出错") + + +@router.get("/rename", summary="重命名文件或目录(阿里云盘)", response_model=schemas.Response) +def rename_aliyun(fileid: str, new_name: str, path: str, + recursive: bool = False, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 重命名文件或目录 + """ + if not fileid or not new_name: + return schemas.Response(success=False) + result = AliyunHelper().rename_file(fileid, new_name) + if result: + if recursive: + transferchain = TransferChain() + media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT + # 递归修改目录内文件(智能识别命名) + sub_files: List[schemas.FileItem] = list_aliyun(path=path, fileid=fileid) + if sub_files: + # 开始进度 + progress = ProgressHelper() + progress.start(ProgressKey.BatchRename) + total = len(sub_files) + handled = 0 + for sub_file in sub_files: + handled += 1 + progress.update(value=handled / total * 100, + text=f"正在处理 {sub_file.name} ...", + key=ProgressKey.BatchRename) + if sub_file.type == "dir": + continue + if not sub_file.extension: + continue + if f".{sub_file.extension.lower()}" not in media_exts: + continue + sub_path = Path(f"{path}{sub_file.name}") + meta = MetaInfoPath(sub_path) + mediainfo = transferchain.recognize_media(meta) + if not mediainfo: + progress.end(ProgressKey.BatchRename) + return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息") + new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo) + if not new_path: + progress.end(ProgressKey.BatchRename) + return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称") + ret: schemas.Response = rename_aliyun(fileid=sub_file.fileid, + path=path, + new_name=Path(new_path).name, + recursive=False) + if not ret.success: + progress.end(ProgressKey.BatchRename) + return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!") + progress.end(ProgressKey.BatchRename) + return schemas.Response(success=True) + return schemas.Response(success=False) + + +@router.get("/image", summary="读取图片(阿里云盘)", response_model=schemas.Response) +def image_aliyun(fileid: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any: + """ + 读取图片 + """ + if not fileid: + return schemas.Response(success=False) + url = AliyunHelper().get_download_url(fileid) + if url: + # 重定向 + return Response(status_code=302, headers={"Location": url}) + raise HTTPException(status_code=500, detail="下载图片出错") diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index a452c75b..972177dd 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from app import schemas from app.chain.dashboard import DashboardChain -from app.core.security import verify_token, verify_uri_token +from app.core.security import verify_token, verify_apitoken from app.db import get_db from app.db.models.transferhistory import TransferHistory from app.helper.directory import DirectoryHelper @@ -36,7 +36,7 @@ def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic) -def statistic2(_: str = Depends(verify_uri_token)) -> Any: +def statistic2(_: str = Depends(verify_apitoken)) -> Any: """ 查询媒体数量统计信息 API_TOKEN认证(?token=xxx) """ @@ -57,7 +57,7 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/storage2", summary="存储空间(API_TOKEN)", response_model=schemas.Storage) -def storage2(_: str = Depends(verify_uri_token)) -> Any: +def storage2(_: str = Depends(verify_apitoken)) -> Any: """ 查询存储空间信息 API_TOKEN认证(?token=xxx) """ @@ -94,7 +94,7 @@ def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/downloader2", summary="下载器信息(API_TOKEN)", response_model=schemas.DownloaderInfo) -def downloader2(_: str = Depends(verify_uri_token)) -> Any: +def downloader2(_: str = Depends(verify_apitoken)) -> Any: """ 查询下载器信息 API_TOKEN认证(?token=xxx) """ @@ -110,7 +110,7 @@ def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/schedule2", summary="后台服务(API_TOKEN)", response_model=List[schemas.ScheduleInfo]) -def schedule2(_: str = Depends(verify_uri_token)) -> Any: +def schedule2(_: str = Depends(verify_apitoken)) -> Any: """ 查询下载器信息 API_TOKEN认证(?token=xxx) """ @@ -136,7 +136,7 @@ def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/cpu2", summary="获取当前CPU使用率(API_TOKEN)", response_model=int) -def cpu2(_: str = Depends(verify_uri_token)) -> Any: +def cpu2(_: str = Depends(verify_apitoken)) -> Any: """ 获取当前CPU使用率 API_TOKEN认证(?token=xxx) """ @@ -152,7 +152,7 @@ def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any: @router.get("/memory2", summary="获取当前内存使用量和使用率(API_TOKEN)", response_model=List[int]) -def memory2(_: str = Depends(verify_uri_token)) -> Any: +def memory2(_: str = Depends(verify_apitoken)) -> Any: """ 获取当前内存使用率 API_TOKEN认证(?token=xxx) """ diff --git a/app/api/endpoints/filebrowser.py b/app/api/endpoints/local.py similarity index 61% rename from app/api/endpoints/filebrowser.py rename to app/api/endpoints/local.py index e37f03bc..e8d08972 100644 --- a/app/api/endpoints/filebrowser.py +++ b/app/api/endpoints/local.py @@ -2,13 +2,17 @@ import shutil from pathlib import Path from typing import Any, List -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from starlette.responses import FileResponse, Response from app import schemas +from app.chain.transfer import TransferChain from app.core.config import settings -from app.core.security import verify_token +from app.core.metainfo import MetaInfoPath +from app.core.security import verify_token, verify_uri_token +from app.helper.progress import ProgressHelper from app.log import logger +from app.schemas.types import ProgressKey from app.utils.system import SystemUtils router = APIRouter() @@ -16,10 +20,10 @@ router = APIRouter() IMAGE_TYPES = [".jpg", ".png", ".gif", ".bmp", ".jpeg", ".webp"] -@router.get("/list", summary="所有目录和文件", response_model=List[schemas.FileItem]) -def list_path(path: str, - sort: str = 'time', - _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get("/list", summary="所有目录和文件(本地)", response_model=List[schemas.FileItem]) +def list_local(path: str, + sort: str = 'time', + _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询当前目录下所有目录和文件 :param path: 目录路径 @@ -98,8 +102,8 @@ def list_path(path: str, return ret_items -@router.get("/listdir", summary="所有目录(不含文件)", response_model=List[schemas.FileItem]) -def list_dir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get("/listdir", summary="所有目录(本地,不含文件)", response_model=List[schemas.FileItem]) +def list_local_dir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询当前目录下所有目录 """ @@ -139,8 +143,8 @@ def list_dir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: return ret_items -@router.get("/mkdir", summary="创建目录", response_model=schemas.Response) -def mkdir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get("/mkdir", summary="创建目录(本地)", response_model=schemas.Response) +def mkdir_local(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 创建目录 """ @@ -153,8 +157,8 @@ def mkdir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: return schemas.Response(success=True) -@router.get("/delete", summary="删除文件或目录", response_model=schemas.Response) -def delete(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get("/delete", summary="删除文件或目录(本地)", response_model=schemas.Response) +def delete_local(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 删除文件或目录 """ @@ -170,19 +174,16 @@ def delete(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: return schemas.Response(success=True) -@router.get("/download", summary="下载文件或目录") -def download(path: str, token: str) -> Any: +@router.get("/download", summary="下载文件(本地)") +def download_local(path: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any: """ 下载文件或目录 """ if not path: return schemas.Response(success=False) - # 认证token - if not verify_token(token): - return None path_obj = Path(path) if not path_obj.exists(): - return schemas.Response(success=False) + raise HTTPException(status_code=404, detail="文件不存在") if path_obj.is_file(): # 做为文件流式下载 return FileResponse(path_obj) @@ -195,8 +196,10 @@ def download(path: str, token: str) -> Any: return reponse -@router.get("/rename", summary="重命名文件或目录", response_model=schemas.Response) -def rename(path: str, new_name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: +@router.get("/rename", summary="重命名文件或目录(本地)", response_model=schemas.Response) +def rename_local(path: str, new_name: str, + recursive: bool = False, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 重命名文件或目录 """ @@ -206,19 +209,53 @@ def rename(path: str, new_name: str, _: schemas.TokenPayload = Depends(verify_to if not path_obj.exists(): return schemas.Response(success=False) path_obj.rename(path_obj.parent / new_name) + if recursive: + transferchain = TransferChain() + media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT + # 递归修改目录内文件(智能识别命名) + sub_files: List[schemas.FileItem] = list_local(path) + if sub_files: + # 开始进度 + progress = ProgressHelper() + progress.start(ProgressKey.BatchRename) + total = len(sub_files) + handled = 0 + for sub_file in sub_files: + handled += 1 + progress.update(value=handled / total * 100, + text=f"正在处理 {sub_file.name} ...", + key=ProgressKey.BatchRename) + if sub_file.type == "dir": + continue + if not sub_file.extension: + continue + if f".{sub_file.extension.lower()}" not in media_exts: + continue + sub_path = Path(sub_file.path) + meta = MetaInfoPath(sub_path) + mediainfo = transferchain.recognize_media(meta) + if not mediainfo: + progress.end(ProgressKey.BatchRename) + return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息") + new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo) + if not new_path: + progress.end(ProgressKey.BatchRename) + return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称") + ret: schemas.Response = rename_local(new_path, new_name=Path(new_path).name, recursive=False) + if not ret.success: + progress.end(ProgressKey.BatchRename) + return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!") + progress.end(ProgressKey.BatchRename) return schemas.Response(success=True) -@router.get("/image", summary="读取图片") -def image(path: str, token: str) -> Any: +@router.get("/image", summary="读取图片(本地)") +def image_local(path: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any: """ 读取图片 """ if not path: return None - # 认证token - if not verify_token(token): - return None path_obj = Path(path) if not path_obj.exists(): return None @@ -226,5 +263,5 @@ def image(path: str, token: str) -> Any: return None # 判断是否图片文件 if path_obj.suffix.lower() not in IMAGE_TYPES: - return None + raise HTTPException(status_code=500, detail="图片读取出错") return Response(content=path_obj.read_bytes(), media_type="image/jpeg") diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 0dcfbf89..3ac19202 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -8,7 +8,7 @@ from app.chain.media import MediaChain from app.core.config import settings from app.core.context import Context from app.core.metainfo import MetaInfo, MetaInfoPath -from app.core.security import verify_token, verify_uri_token +from app.core.security import verify_token, verify_apitoken from app.schemas import MediaType router = APIRouter() @@ -32,7 +32,7 @@ def recognize(title: str, @router.get("/recognize2", summary="识别种子媒体信息(API_TOKEN)", response_model=schemas.Context) def recognize2(title: str, subtitle: str = None, - _: str = Depends(verify_uri_token)) -> Any: + _: str = Depends(verify_apitoken)) -> Any: """ 根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx) """ @@ -55,7 +55,7 @@ def recognize_file(path: str, @router.get("/recognize_file2", summary="识别文件媒体信息(API_TOKEN)", response_model=schemas.Context) def recognize_file2(path: str, - _: str = Depends(verify_uri_token)) -> Any: + _: str = Depends(verify_apitoken)) -> Any: """ 根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx) """ diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 18bb5a69..beaf3b94 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -10,7 +10,7 @@ from app.chain.subscribe import SubscribeChain from app.core.config import settings from app.core.context import MediaInfo from app.core.metainfo import MetaInfo -from app.core.security import verify_token, verify_uri_token +from app.core.security import verify_token, verify_apitoken from app.db import get_db from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory @@ -52,7 +52,7 @@ def read_subscribes( @router.get("/list", summary="查询所有订阅(API_TOKEN)", response_model=List[schemas.Subscribe]) -def list_subscribes(_: str = Depends(verify_uri_token)) -> Any: +def list_subscribes(_: str = Depends(verify_apitoken)) -> Any: """ 查询所有订阅 API_TOKEN认证(?token=xxx) """ diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index b791af15..732d6d97 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -5,8 +5,10 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app import schemas +from app.chain.media import MediaChain from app.chain.transfer import TransferChain -from app.core.security import verify_token, verify_uri_token +from app.core.metainfo import MetaInfoPath +from app.core.security import verify_token, verify_apitoken from app.db import get_db from app.db.models.transferhistory import TransferHistory from app.schemas import MediaType @@ -14,6 +16,35 @@ from app.schemas import MediaType router = APIRouter() +@router.get("/name", summary="查询整理后的名称", response_model=schemas.Response) +def query_name(path: str, filetype: str, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 查询整理后的名称 + :param path: 文件路径 + :param filetype: 文件类型 + :param _: Token校验 + """ + meta = MetaInfoPath(Path(path)) + mediainfo = MediaChain().recognize_media(meta) + if not mediainfo: + return schemas.Response(success=False, message="未识别到媒体信息") + new_path = TransferChain().recommend_name(meta=meta, mediainfo=mediainfo) + if not new_path: + return schemas.Response(success=False, message="未识别到新名称") + if filetype == "dir": + parents = Path(new_path).parents + if len(parents) > 2: + new_name = parents[1].name + else: + new_name = parents[0].name + else: + new_name = Path(new_path).name + return schemas.Response(success=True, data={ + "name": new_name + }) + + @router.post("/manual", summary="手动转移", response_model=schemas.Response) def manual_transfer(path: str = None, logid: int = None, @@ -110,7 +141,7 @@ def manual_transfer(path: str = None, @router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response) -def now(_: str = Depends(verify_uri_token)) -> Any: +def now(_: str = Depends(verify_apitoken)) -> Any: """ 立即执行下载器文件整理 API_TOKEN认证(?token=xxx) """ diff --git a/app/api/endpoints/u115.py b/app/api/endpoints/u115.py new file mode 100644 index 00000000..df54a038 --- /dev/null +++ b/app/api/endpoints/u115.py @@ -0,0 +1,229 @@ +import base64 +from pathlib import Path +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException +from starlette.responses import Response + +from app import schemas +from app.chain.transfer import TransferChain +from app.core.config import settings +from app.core.metainfo import MetaInfoPath +from app.core.security import verify_token, verify_uri_token +from app.helper.progress import ProgressHelper +from app.helper.u115 import U115Helper +from app.schemas.types import ProgressKey +from app.utils.http import RequestUtils + +router = APIRouter() + + +@router.get("/qrcode", summary="生成二维码内容", response_model=schemas.Response) +def qrcode(_: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 生成二维码 + """ + qrcode_data = U115Helper().generate_qrcode() + if qrcode_data: + return schemas.Response(success=True, data={ + 'codeContent': qrcode_data + }) + return schemas.Response(success=False) + + +@router.get("/check", summary="二维码登录确认", response_model=schemas.Response) +def check(_: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 二维码登录确认 + """ + data, errmsg = U115Helper().check_login() + if data: + return schemas.Response(success=True, data=data) + return schemas.Response(success=False, message=errmsg) + + +@router.get("/storage", summary="查询存储空间信息", response_model=schemas.Response) +def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 查询存储空间信息 + """ + storage_info = U115Helper().get_storage() + if storage_info: + return schemas.Response(success=True, data={ + "total": storage_info[0], + "used": storage_info[1] + }) + return schemas.Response(success=False) + + +@router.get("/list", summary="所有目录和文件(115网盘)", response_model=List[schemas.FileItem]) +def list_115(path: str, + fileid: str, + pickcode: str = None, + filetype: str = "dir", + sort: str = 'updated_at', + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 查询当前目录下所有目录和文件 + :param path: 当前路径 + :param fileid: 文件ID + :param pickcode: 115 pickcode + :param filetype: 文件类型 + :param sort: 排序方式,name:按名称排序,time:按修改时间排序 + :param _: token + :return: 所有目录和文件 + """ + if not fileid: + return [] + if not path: + path = "/" + if fileid == "root": + fileid = "0" + if filetype == "file": + name = Path(path).name + suffix = Path(name).suffix[1:] + return [schemas.FileItem( + fileid=fileid, + type="file", + path=path.rstrip('/'), + name=name, + extension=suffix, + pickcode=pickcode + )] + items = U115Helper().list_files(parent_file_id=fileid) + if not items: + return [] + file_list = [schemas.FileItem( + fileid=item.file_id, + parent_fileid=item.parent_id, + type="dir" if item.is_dir else "file", + path=f"{path}{item.name}" + "/" if item.is_dir else "", + name=item.name, + size=item.size, + extension=Path(item.name).suffix[1:], + modify_time=item.modified_time.timestamp() if item.modified_time else 0, + pickcode=item.pickcode + ) for item in items] + if sort == "name": + file_list.sort(key=lambda x: x.name) + else: + file_list.sort(key=lambda x: x.modify_time, reverse=True) + return file_list + + +@router.get("/mkdir", summary="创建目录(115网盘)", response_model=schemas.Response) +def mkdir_115(fileid: str, + name: str, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 创建目录 + """ + if not fileid or not name: + return schemas.Response(success=False) + result = U115Helper().create_folder(parent_file_id=fileid, name=name) + if result: + return schemas.Response(success=True) + return schemas.Response(success=False) + + +@router.get("/delete", summary="删除文件或目录(115网盘)", response_model=schemas.Response) +def delete_115(fileid: str, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 删除文件或目录 + """ + if not fileid: + return schemas.Response(success=False) + result = U115Helper().delete_file(fileid) + if result: + return schemas.Response(success=True) + return schemas.Response(success=False) + + +@router.get("/download", summary="下载文件(115网盘)") +def download_115(pickcode: str, + _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any: + """ + 下载文件或目录 + """ + if not pickcode: + return schemas.Response(success=False) + ticket = U115Helper().download(pickcode) + if ticket: + # 请求数据,并以文件流的方式返回 + res = RequestUtils(headers=ticket.headers).get_res(ticket.url) + if res: + return Response(content=res.content, media_type="application/octet-stream") + return schemas.Response(success=False) + + +@router.get("/rename", summary="重命名文件或目录(115网盘)", response_model=schemas.Response) +def rename_115(fileid: str, new_name: str, path: str, + recursive: bool = False, + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 重命名文件或目录 + """ + if not fileid or not new_name: + return schemas.Response(success=False) + result = U115Helper().rename_file(fileid, new_name) + if result: + if recursive: + transferchain = TransferChain() + media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT + # 递归修改目录内文件(智能识别命名) + sub_files: List[schemas.FileItem] = list_115(path=path, fileid=fileid) + if sub_files: + # 开始进度 + progress = ProgressHelper() + progress.start(ProgressKey.BatchRename) + total = len(sub_files) + handled = 0 + for sub_file in sub_files: + handled += 1 + progress.update(value=handled / total * 100, + text=f"正在处理 {sub_file.name} ...", + key=ProgressKey.BatchRename) + if sub_file.type == "dir": + continue + if not sub_file.extension: + continue + if f".{sub_file.extension.lower()}" not in media_exts: + continue + sub_path = Path(f"{path}{sub_file.name}") + meta = MetaInfoPath(sub_path) + mediainfo = transferchain.recognize_media(meta) + if not mediainfo: + progress.end(ProgressKey.BatchRename) + return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息") + new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo) + if not new_path: + progress.end(ProgressKey.BatchRename) + return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称") + ret: schemas.Response = rename_115(fileid=sub_file.fileid, + path=path, + new_name=Path(new_path).name, + recursive=False) + if not ret.success: + progress.end(ProgressKey.BatchRename) + return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!") + progress.end(ProgressKey.BatchRename) + return schemas.Response(success=True) + return schemas.Response(success=False) + + +@router.get("/image", summary="读取图片(115网盘)") +def image_115(pickcode: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any: + """ + 读取图片 + """ + if not pickcode: + return schemas.Response(success=False) + ticket = U115Helper().download(pickcode) + if ticket: + # 请求数据,获取内容编码为图片base64返回 + res = RequestUtils(headers=ticket.headers).get_res(ticket.url) + if res: + content_type = res.headers.get("Content-Type") + return Response(content=res.content, media_type=content_type) + raise HTTPException(status_code=500, detail="下载图片出错") diff --git a/app/api/endpoints/webhook.py b/app/api/endpoints/webhook.py index f516dcb2..6be98a47 100644 --- a/app/api/endpoints/webhook.py +++ b/app/api/endpoints/webhook.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, BackgroundTasks, Request, Depends from app import schemas from app.chain.webhook import WebhookChain -from app.core.security import verify_uri_token +from app.core.security import verify_apitoken router = APIRouter() @@ -19,7 +19,7 @@ def start_webhook_chain(body: Any, form: Any, args: Any): @router.post("/", summary="Webhook消息响应", response_model=schemas.Response) async def webhook_message(background_tasks: BackgroundTasks, request: Request, - _: str = Depends(verify_uri_token) + _: str = Depends(verify_apitoken) ) -> Any: """ Webhook响应 @@ -33,7 +33,7 @@ async def webhook_message(background_tasks: BackgroundTasks, @router.get("/", summary="Webhook消息响应", response_model=schemas.Response) def webhook_message(background_tasks: BackgroundTasks, - request: Request, _: str = Depends(verify_uri_token)) -> Any: + request: Request, _: str = Depends(verify_apitoken)) -> Any: """ Webhook响应 """ diff --git a/app/api/servarr.py b/app/api/servarr.py index b1216ac3..791feecc 100644 --- a/app/api/servarr.py +++ b/app/api/servarr.py @@ -7,7 +7,7 @@ from app import schemas from app.chain.media import MediaChain from app.chain.subscribe import SubscribeChain from app.core.metainfo import MetaInfo -from app.core.security import verify_uri_apikey +from app.core.security import verify_apikey from app.db import get_db from app.db.models.subscribe import Subscribe from app.schemas import RadarrMovie, SonarrSeries @@ -18,7 +18,7 @@ arr_router = APIRouter(tags=['servarr']) @arr_router.get("/system/status", summary="系统状态") -def arr_system_status(_: str = Depends(verify_uri_apikey)) -> Any: +def arr_system_status(_: str = Depends(verify_apikey)) -> Any: """ 模拟Radarr、Sonarr系统状态 """ @@ -72,7 +72,7 @@ def arr_system_status(_: str = Depends(verify_uri_apikey)) -> Any: @arr_router.get("/qualityProfile", summary="质量配置") -def arr_qualityProfile(_: str = Depends(verify_uri_apikey)) -> Any: +def arr_qualityProfile(_: str = Depends(verify_apikey)) -> Any: """ 模拟Radarr、Sonarr质量配置 """ @@ -113,7 +113,7 @@ def arr_qualityProfile(_: str = Depends(verify_uri_apikey)) -> Any: @arr_router.get("/rootfolder", summary="根目录") -def arr_rootfolder(_: str = Depends(verify_uri_apikey)) -> Any: +def arr_rootfolder(_: str = Depends(verify_apikey)) -> Any: """ 模拟Radarr、Sonarr根目录 """ @@ -129,7 +129,7 @@ def arr_rootfolder(_: str = Depends(verify_uri_apikey)) -> Any: @arr_router.get("/tag", summary="标签") -def arr_tag(_: str = Depends(verify_uri_apikey)) -> Any: +def arr_tag(_: str = Depends(verify_apikey)) -> Any: """ 模拟Radarr、Sonarr标签 """ @@ -142,7 +142,7 @@ def arr_tag(_: str = Depends(verify_uri_apikey)) -> Any: @arr_router.get("/languageprofile", summary="语言") -def arr_languageprofile(_: str = Depends(verify_uri_apikey)) -> Any: +def arr_languageprofile(_: str = Depends(verify_apikey)) -> Any: """ 模拟Radarr、Sonarr语言 """ @@ -168,7 +168,7 @@ def arr_languageprofile(_: str = Depends(verify_uri_apikey)) -> Any: @arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie]) -def arr_movies(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db)) -> Any: +def arr_movies(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -> Any: """ 查询Rardar电影 """ @@ -259,7 +259,7 @@ def arr_movies(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db @arr_router.get("/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie]) -def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any: +def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any: """ 查询Rardar电影 term: `tmdb:${id}` 存在和不存在均不能返回错误 @@ -305,7 +305,7 @@ def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends( @arr_router.get("/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie) -def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any: +def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any: """ 查询Rardar电影订阅 """ @@ -333,7 +333,7 @@ def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_u @arr_router.post("/movie", summary="新增电影订阅") def arr_add_movie(movie: RadarrMovie, db: Session = Depends(get_db), - _: str = Depends(verify_uri_apikey) + _: str = Depends(verify_apikey) ) -> Any: """ 新增Rardar电影订阅 @@ -362,7 +362,7 @@ def arr_add_movie(movie: RadarrMovie, @arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response) -def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any: +def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any: """ 删除Rardar电影订阅 """ @@ -378,7 +378,7 @@ def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(v @arr_router.get("/series", summary="所有剧集", response_model=List[schemas.SonarrSeries]) -def arr_series(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db)) -> Any: +def arr_series(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -> Any: """ 查询Sonarr剧集 """ @@ -514,7 +514,7 @@ def arr_series(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db @arr_router.get("/series/lookup", summary="查询剧集") -def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any: +def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any: """ 查询Sonarr剧集 term: `tvdb:${id}` title """ @@ -603,7 +603,7 @@ def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends @arr_router.get("/series/{tid}", summary="剧集详情") -def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any: +def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any: """ 查询Sonarr剧集 """ @@ -639,7 +639,7 @@ def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_u @arr_router.post("/series", summary="新增剧集订阅") def arr_add_series(tv: schemas.SonarrSeries, db: Session = Depends(get_db), - _: str = Depends(verify_uri_apikey)) -> Any: + _: str = Depends(verify_apikey)) -> Any: """ 新增Sonarr剧集订阅 """ @@ -681,7 +681,7 @@ def arr_add_series(tv: schemas.SonarrSeries, @arr_router.delete("/series/{tid}", summary="删除剧集订阅") -def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any: +def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any: """ 删除Sonarr剧集订阅 """ diff --git a/app/chain/site.py b/app/chain/site.py index 214b5a34..146a51d2 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -107,7 +107,8 @@ class SiteChain(ChainBase): 判断站点是否已经登陆:m-team """ user_agent = site.ua or settings.USER_AGENT - url = f"{site.url}api/member/profile" + domain = StringUtils.get_url_domain(site.url) + url = f"https://api.{domain}/api/member/profile" headers = { "Content-Type": "application/json", "User-Agent": user_agent, @@ -127,7 +128,7 @@ class SiteChain(ChainBase): timeout=site.timeout or 15, proxies=settings.PROXY if site.proxy else None, referer=f"{site.url}index" - ).post_res(url=urljoin(url, "api/member/updateLastBrowse")) + ).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse") if res: return True, "连接成功" else: diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 282b8e48..2b4eff4a 100644 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -44,6 +44,15 @@ class TransferChain(ChainBase): self.systemconfig = SystemConfigOper() self.directoryhelper = DirectoryHelper() + def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]: + """ + 获取重命名后的名称 + :param meta: 元数据 + :param mediainfo: 媒体信息 + :return: 重命名后的名称(含目录) + """ + return self.run_module("recommend_name", meta=meta, mediainfo=mediainfo) + def process(self) -> bool: """ 获取下载器中的种子列表,并执行转移 diff --git a/app/core/security.py b/app/core/security.py index 3fbb57c1..1888a13a 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -61,21 +61,21 @@ def verify_token(token: str = Depends(reusable_oauth2)) -> schemas.TokenPayload: ) -def get_token(token: str = None) -> str: +def __get_token(token: str = None) -> str: """ 从请求URL中获取token """ return token -def get_apikey(apikey: str = None, x_api_key: Annotated[str | None, Header()] = None) -> str: +def __get_apikey(apikey: str = None, x_api_key: Annotated[str | None, Header()] = None) -> str: """ 从请求URL中获取apikey """ return apikey or x_api_key -def verify_uri_token(token: str = Depends(get_token)) -> str: +def verify_apitoken(token: str = Depends(__get_token)) -> str: """ 通过依赖项使用token进行身份认证 """ @@ -87,7 +87,7 @@ def verify_uri_token(token: str = Depends(get_token)) -> str: return token -def verify_uri_apikey(apikey: str = Depends(get_apikey)) -> str: +def verify_apikey(apikey: str = Depends(__get_apikey)) -> str: """ 通过依赖项使用apikey进行身份认证 """ @@ -99,6 +99,18 @@ def verify_uri_apikey(apikey: str = Depends(get_apikey)) -> str: return apikey +def verify_uri_token(token: str = Depends(__get_token)) -> str: + """ + 通过依赖项使用token进行身份认证 + """ + if not verify_token(token): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="token校验不通过" + ) + return token + + def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) diff --git a/app/helper/aliyun.py b/app/helper/aliyun.py new file mode 100644 index 00000000..54bf1271 --- /dev/null +++ b/app/helper/aliyun.py @@ -0,0 +1,450 @@ +import base64 +import json +import time +import uuid +from typing import Optional, Tuple, List + +from requests import Response + +from app.core.config import settings +from app.db.systemconfig_oper import SystemConfigOper +from app.log import logger +from app.schemas.types import SystemConfigKey +from app.utils.http import RequestUtils +from app.utils.system import SystemUtils + + +class AliyunHelper: + """ + 阿里云相关操作 + """ + + _X_SIGNATURE = ('f4b7bed5d8524a04051bd2da876dd79afe922b8205226d65855d02b267422adb1' + 'e0d8a816b021eaf5c36d101892180f79df655c5712b348c2a540ca136e6b22001') + + _X_PUBLIC_KEY = ('04d9d2319e0480c840efeeb75751b86d0db0c5b9e72c6260a1d846958adceaf9d' + 'ee789cab7472741d23aafc1a9c591f72e7ee77578656e6c8588098dea1488ac2a') + + # 生成二维码 + qrcode_url = ("https://passport.aliyundrive.com/newlogin/qrcode/generate.do?" + "appName=aliyun_drive&fromSite=52&appEntrance=web&isMobile=false" + "&lang=zh_CN&returnUrl=&bizParams=&_bx-v=2.0.31") + # 二维码登录确认 + check_url = "https://passport.aliyundrive.com/newlogin/qrcode/query.do?appName=aliyun_drive&fromSite=52&_bx-v=2.0.31" + # 更新访问令牌 + update_accessstoken_url = "https://auth.aliyundrive.com/v2/account/token" + # 创建会话 + create_session_url = "https://api.aliyundrive.com/users/v1/users/device/create_session" + # 用户信息 + user_info_url = "https://user.aliyundrive.com/v2/user/get" + # 浏览文件 + list_file_url = "https://api.aliyundrive.com/adrive/v3/file/list" + # 创建目录 + create_folder_url = "https://api.aliyundrive.com/adrive/v2/file/createWithFolders" + # 文件详情 + file_detail_url = "https://api.aliyundrive.com/v2/file/get" + # 删除文件 + delete_file_url = " https://api.aliyundrive.com/v2/recyclebin/trash" + # 文件重命名 + rename_file_url = "https://api.aliyundrive.com/v3/file/update" + # 获取下载链接 + download_url = "https://api.aliyundrive.com/v2/file/get_download_url" + + def __init__(self): + self.systemconfig = SystemConfigOper() + + def __handle_error(self, res: Response, apiname: str, action: bool = True): + """ + 统一处理和打印错误信息 + """ + if res is None: + logger.warn("无法连接到阿里云盘!") + return + result = res.json() + code = result.get("code") + message = result.get("message") + display_message = result.get("display_message") + if code or message: + logger.warn(f"Aliyun {apiname}失败:{code} - {display_message or message}") + if action: + if code == "DeviceSessionSignatureInvalid": + logger.warn("设备已失效,正在重新建立会话...") + self.create_session(self.get_headers(self.auth_params)) + if code == "UserDeviceOffline": + logger.warn("设备已离线,尝试重新登录,如仍报错请检查阿里云盘绑定设备数量是否超限!") + self.create_session(self.get_headers(self.auth_params)) + if code == "AccessTokenInvalid": + logger.warn("访问令牌已失效,正在刷新令牌...") + self.__update_accesstoken(self.auth_params, self.auth_params.get("refreshToken")) + else: + logger.info(f"Aliyun {apiname}成功") + + @property + def auth_params(self): + """ + 获取阿里云盘认证参数并初始化参数格式 + """ + return self.systemconfig.get(SystemConfigKey.UserAliyunParams) or {} + + def update_params(self, params: dict): + """ + 设置阿里云盘认证参数 + """ + current_params = self.auth_params + current_params.update(params) + self.systemconfig.set(SystemConfigKey.UserAliyunParams, current_params) + + def clear_params(self): + """ + 清除阿里云盘认证参数 + """ + self.systemconfig.delete(SystemConfigKey.UserAliyunParams) + + def generate_qrcode(self) -> Optional[Tuple[dict, str]]: + """ + 生成二维码 + """ + res = RequestUtils(timeout=10).get_res(self.qrcode_url) + if res: + data = res.json().get("content", {}).get("data") + return { + "codeContent": data.get("codeContent"), + "ck": data.get("ck"), + "t": data.get("t") + }, "" + elif res is not None: + self.__handle_error(res, "生成二维码") + return {}, f"请求阿里云盘二维码失败:{res.status_code} - {res.reason}" + return {}, f"请求阿里云盘二维码失败:无法连接!" + + def check_login(self, ck: str, t: str) -> Optional[Tuple[dict, str]]: + """ + 二维码登录确认 + """ + params = { + "t": t, + "ck": ck, + "appName": "aliyun_drive", + "appEntrance": "web", + "isMobile": "false", + "lang": "zh_CN", + "returnUrl": "", + "fromSite": "52", + "bizParams": "", + "navlanguage": "zh-CN", + "navPlatform": "MacIntel", + } + + body = "&".join([f"{key}={value}" for key, value in params.items()]) + + status = { + "NEW": "请用阿里云盘 App 扫码", + "SCANED": "请在手机上确认", + "EXPIRED": "二维码已过期", + "CANCELED": "已取消", + "CONFIRMED": "已确认", + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + } + + res = RequestUtils(headers=headers, timeout=5).post_res(self.check_url, data=body) + if res: + data = res.json().get("content", {}).get("data") or {} + qrCodeStatus = data.get("qrCodeStatus") + data["tip"] = status.get(qrCodeStatus) or "未知" + if data.get("bizExt"): + try: + bizExt = json.loads(base64.b64decode(data["bizExt"]).decode('GBK')) + pds_login_result = bizExt.get("pds_login_result") + if pds_login_result: + data.pop('bizExt') + data.update({ + 'userId': pds_login_result.get('userId'), + 'expiresIn': pds_login_result.get('expiresIn'), + 'nickName': pds_login_result.get('nickName'), + 'avatar': pds_login_result.get('avatar'), + 'tokenType': pds_login_result.get('tokenType'), + "refreshToken": pds_login_result.get('refreshToken'), + "accessToken": pds_login_result.get('accessToken'), + "defaultDriveId": pds_login_result.get('defaultDriveId'), + "updateTime": time.time(), + }) + self.update_params(data) + self.get_user_info() + except Exception as e: + return {}, f"bizExt 解码失败:{str(e)}" + return data, "" + elif res is not None: + self.__handle_error(res, "登录确认") + return {}, f"阿里云盘登录确认失败:{res.status_code} - {res.reason}" + return {}, "阿里云盘登录确认失败:无法连接!" + + def __update_accesstoken(self, params: dict, refresh_token: str) -> bool: + """ + 更新阿里云盘访问令牌 + """ + headers = self.get_headers(params) + res = RequestUtils(headers=headers, timeout=10).post_res( + self.update_accessstoken_url, json={ + "refresh_token": refresh_token, + "grant_type": "refresh_token" + }) + if res: + data = res.json() + code = data.get("code") + if code in ["RefreshTokenExpired", "InvalidParameter.RefreshToken"]: + logger.warn("刷新令牌已过期,请重新登录!") + self.clear_params() + return False + self.update_params({ + "accessToken": data.get('access_token'), + "expiresIn": data.get('expires_in'), + "updateTime": time.time() + }) + logger.info(f"阿里云盘访问令牌已更新,accessToken={data.get('access_token')}") + return True + else: + self.__handle_error(res, "更新令牌", action=False) + return False + + def create_session(self, headers: dict): + """ + 创建会话 + """ + def __os_name(): + """ + 获取操作系统名称 + """ + if SystemUtils.is_windows(): + return 'Windows 操作系统' + elif SystemUtils.is_macos(): + return 'MacOS 操作系统' + else: + return '类 Unix 操作系统' + + res = RequestUtils(headers=headers, timeout=5).post_res(self.create_session_url, json={ + 'deviceName': f'MoviePilot {SystemUtils.platform}', + 'modelName': __os_name(), + 'pubKey': self._X_PUBLIC_KEY, + }) + self.__handle_error(res, "创建会话", action=False) + + def get_access_params(self) -> Optional[dict]: + """ + 获取阿里云盘访问参数,如果超时则更新后返回 + """ + params = self.auth_params + if not params: + logger.warn("阿里云盘访问令牌不存在,请先扫码登录!") + return None + expires_in = params.get("expiresIn") + update_time = params.get("updateTime") + refresh_token = params.get("refreshToken") + if not expires_in or not update_time or not refresh_token: + logger.warn("阿里云盘访问令牌参数错误,请重新扫码登录!") + self.clear_params() + return None + # 是否需要更新设备信息 + update_device = False + # 判断访问令牌是否过期 + if (time.time() - update_time) >= expires_in: + logger.info("阿里云盘访问令牌已过期,正在更新...") + if not self.__update_accesstoken(params, refresh_token): + # 更新失败 + return None + update_device = True + # 生成设备ID + x_device_id = params.get("x_device_id") + if not x_device_id: + x_device_id = uuid.uuid4().hex + params['x_device_id'] = x_device_id + self.update_params({"x_device_id": x_device_id}) + update_device = True + # 更新设备信息重新创建会话 + if update_device: + self.create_session(self.get_headers(params)) + return params + + def get_headers(self, params: dict): + """ + 获取请求头 + """ + if not params: + return {} + return { + "Authorization": f"Bearer {params.get('accessToken')}", + "Content-Type": "application/json;charset=UTF-8", + "Accept": "application/json, text/plain, */*", + "Referer": "https://www.alipan.com/", + "User-Agent": settings.USER_AGENT, + "X-Canary": "client=web,app=adrive,version=v4.9.0", + "x-device-id": params.get('x_device_id'), + "x-signature": self._X_SIGNATURE + } + + def get_user_info(self) -> dict: + """ + 获取用户信息(drive_id等) + """ + params = self.get_access_params() + if not params: + return {} + headers = self.get_headers(params) + res = RequestUtils(headers=headers, timeout=10).post_res(self.user_info_url) + if res: + result = res.json() + self.update_params({ + "resourceDriveId": result.get("resource_drive_id"), + "backDriveId": result.get("backup_drive_id") + }) + return result + else: + self.__handle_error(res, "获取用户信息") + return {} + + def list_files(self, parent_file_id: str = 'root', list_type: str = None, + limit: int = 100, order_by: str = 'updated_at') -> List[dict]: + """ + 浏览文件 + limit 返回文件数量,默认 50,最大 100 + order_by created_at/updated_at/name/size + parent_file_id 根目录为root + type all | file | folder + """ + params = self.get_access_params() + if not params: + return [] + # 最终返回数据 + ret_items = [] + # 请求头 + headers = self.get_headers(params) + # 分页获取 + next_marker = None + while True: + if not parent_file_id or parent_file_id == "/": + parent_file_id = "root" + res = RequestUtils(headers=headers, timeout=10).post_res(self.list_file_url, json={ + "drive_id": params.get("resourceDriveId"), + "type": list_type, + "limit": limit, + "order_by": order_by, + "parent_file_id": parent_file_id, + "marker": next_marker + }, params={ + 'jsonmask': ('next_marker,items(name,file_id,drive_id,type,size,created_at,updated_at,' + 'category,file_extension,parent_file_id,mime_type,starred,thumbnail,url,' + 'streams_info,content_hash,user_tags,user_meta,trashed,video_media_metadata,' + 'video_preview_metadata,sync_meta,sync_device_flag,sync_flag,punish_flag') + }) + if res: + result = res.json() + items = result.get("items") + if not items: + break + # 合并数据 + ret_items.extend(items) + next_marker = result.get("next_marker") + if not next_marker: + # 没有下一页 + break + else: + self.__handle_error(res, "浏览文件") + break + return ret_items + + def create_folder(self, parent_file_id: str, name: str) -> bool: + """ + 创建目录 + """ + params = self.get_access_params() + if not params: + return False + headers = self.get_headers(params) + res = RequestUtils(headers=headers, timeout=10).post_res(self.create_folder_url, json={ + "drive_id": params.get("resourceDriveId"), + "parent_file_id": parent_file_id, + "name": name, + "check_name_mode": "refuse", + "type": "folder" + }) + if res: + return True + else: + self.__handle_error(res, "创建目录") + return False + + def delete_file(self, file_id: str) -> bool: + """ + 删除文件 + """ + params = self.get_access_params() + if not params: + return False + headers = self.get_headers(params) + res = RequestUtils(headers=headers, timeout=10).post_res(self.delete_file_url, json={ + "drive_id": params.get("resourceDriveId"), + "file_id": file_id + }) + if res: + return True + else: + self.__handle_error(res, "删除文件") + return False + + def get_file_detail(self, file_id: str) -> Optional[dict]: + """ + 获取文件详情 + """ + params = self.get_access_params() + if not params: + return None + headers = self.get_headers(params) + res = RequestUtils(headers=headers, timeout=10).post_res(self.file_detail_url, json={ + "drive_id": params.get("resourceDriveId"), + "file_id": file_id + }) + if res: + return res.json() + else: + self.__handle_error(res, "获取文件详情") + return None + + def rename_file(self, file_id: str, name: str) -> bool: + """ + 重命名文件 + """ + params = self.get_access_params() + if not params: + return False + headers = self.get_headers(params) + res = RequestUtils(headers=headers, timeout=10).post_res(self.rename_file_url, json={ + "drive_id": params.get("resourceDriveId"), + "file_id": file_id, + "name": name, + "check_name_mode": "refuse" + }) + if res: + return True + else: + self.__handle_error(res, "重命名文件") + return False + + def get_download_url(self, file_id: str) -> Optional[str]: + """ + 获取下载链接 + """ + params = self.get_access_params() + if not params: + return None + headers = self.get_headers(params) + res = RequestUtils(headers=headers, timeout=10).post_res(self.download_url, json={ + "drive_id": params.get("resourceDriveId"), + "file_id": file_id + }) + if res: + return res.json().get("url") + else: + self.__handle_error(res, "获取下载链接") + return None diff --git a/app/helper/progress.py b/app/helper/progress.py index 4d1004ce..67294f6c 100644 --- a/app/helper/progress.py +++ b/app/helper/progress.py @@ -34,7 +34,11 @@ class ProgressHelper(metaclass=Singleton): key = key.value if not self._process_detail.get(key): return - self._process_detail[key]['enable'] = False + self._process_detail[key] = { + "enable": False, + "value": 100, + "text": "正在处理..." + } def update(self, key: Union[ProgressKey, str], value: float = None, text: str = None): if isinstance(key, Enum): diff --git a/app/helper/u115.py b/app/helper/u115.py new file mode 100644 index 00000000..e592399a --- /dev/null +++ b/app/helper/u115.py @@ -0,0 +1,206 @@ +import base64 +from typing import Optional, Tuple, Generator + +import py115 +from py115 import Cloud +from py115.types import LoginTarget, QrcodeSession, QrcodeStatus, Credential, File, DownloadTicket + +from app.db.systemconfig_oper import SystemConfigOper +from app.log import logger +from app.schemas.types import SystemConfigKey +from app.utils.singleton import Singleton +from app.utils.system import SystemUtils + + +class U115Helper(metaclass=Singleton): + """ + 115相关操作 + """ + + cloud: Optional[Cloud] = None + _session: QrcodeSession = None + + def __init__(self): + self.systemconfig = SystemConfigOper() + + def __init_cloud(self) -> bool: + """ + 初始化Cloud + """ + credential = self.credential + if not credential: + logger.warn("115未登录,请先登录!") + return False + try: + if not self.cloud: + self.cloud = py115.connect(credential) + except Exception as err: + logger.error(f"115连接失败,请重新扫码登录:{str(err)}") + self.clear_credential() + return False + return True + + @property + def credential(self) -> Optional[Credential]: + """ + 获取已保存的115认证参数 + """ + cookie_dict = self.systemconfig.get(SystemConfigKey.User115Params) + if not cookie_dict: + return None + return Credential.from_dict(cookie_dict) + + def save_credentail(self, credential: Credential): + """ + 设置115认证参数 + """ + self.systemconfig.set(SystemConfigKey.User115Params, credential.to_dict()) + + def clear_credential(self): + """ + 清除115认证参数 + """ + self.systemconfig.delete(SystemConfigKey.User115Params) + + def generate_qrcode(self) -> Optional[str]: + """ + 生成二维码 + """ + try: + self.cloud = py115.connect() + self._session = self.cloud.qrcode_login(LoginTarget.Web) + image_bin = self._session.image_data + if not image_bin: + logger.warn("115生成二维码失败:未获取到二维码数据!") + return None + # 转换为base64图片格式 + image_base64 = base64.b64encode(image_bin).decode() + return f"data:image/png;base64,{image_base64}" + except Exception as e: + logger.warn(f"115生成二维码失败:{str(e)}") + return None + + def check_login(self) -> Optional[Tuple[dict, str]]: + """ + 二维码登录确认 + """ + if not self._session: + return {}, "请先生成二维码!" + try: + if not self.cloud: + return {}, "请先生成二维码!" + status = self.cloud.qrcode_poll(self._session) + if status == QrcodeStatus.Done: + # 确认完成,保存认证信息 + self.save_credentail(self.cloud.export_credentail()) + result = { + "status": 1, + "tip": "登录成功!" + } + elif status == QrcodeStatus.Waiting: + result = { + "status": 0, + "tip": "请使用微信或115客户端扫码" + } + elif status == QrcodeStatus.Expired: + result = { + "status": -1, + "tip": "二维码已过期,请重新刷新!" + } + self.cloud = None + elif status == QrcodeStatus.Failed: + result = { + "status": -2, + "tip": "登录失败,请重试!" + } + self.cloud = None + else: + result = { + "status": -3, + "tip": "未知错误,请重试!" + } + self.cloud = None + return result, "" + except Exception as e: + return {}, f"115登录确认失败:{str(e)}" + + def list_files(self, parent_file_id: str = '0') -> Optional[Generator[File, None, None]]: + """ + 浏览文件 + """ + if not self.__init_cloud(): + return None + try: + return self.cloud.storage().list(dir_id=parent_file_id) + except Exception as e: + logger.error(f"浏览115文件失败:{str(e)}") + return None + + def create_folder(self, parent_file_id: str, name: str) -> bool: + """ + 创建目录 + """ + if not self.__init_cloud(): + return False + try: + self.cloud.storage().make_dir(parent_file_id, name) + return True + except Exception as e: + logger.error(f"创建115目录失败:{str(e)}") + return False + + def delete_file(self, file_id: str) -> bool: + """ + 删除文件 + """ + if not self.__init_cloud(): + return False + try: + self.cloud.storage().delete(file_id) + return True + except Exception as e: + logger.error(f"删除115文件失败:{str(e)}") + return False + + def get_file_detail(self, file_id: str) -> Optional[dict]: + """ + 获取文件详情 + """ + pass + + def rename_file(self, file_id: str, name: str) -> bool: + """ + 重命名文件 + """ + if not self.__init_cloud(): + return False + try: + self.cloud.storage().rename(file_id, name) + return True + except Exception as e: + logger.error(f"重命名115文件失败:{str(e)}") + return False + + def download(self, pickcode: str) -> Optional[DownloadTicket]: + """ + 获取下载链接 + """ + if not self.__init_cloud(): + return None + try: + return self.cloud.storage().request_download(pickcode) + except Exception as e: + logger.error(f"115下载失败:{str(e)}") + return None + + def get_storage(self) -> Optional[Tuple[int, int]]: + """ + 获取存储空间 + """ + if not self.__init_cloud(): + return None + try: + return self.cloud.storage().space() + except Exception as e: + logger.error(f"获取115存储空间失败:{str(e)}") + return None diff --git a/app/modules/filetransfer/__init__.py b/app/modules/filetransfer/__init__.py index caf198d9..45345736 100644 --- a/app/modules/filetransfer/__init__.py +++ b/app/modules/filetransfer/__init__.py @@ -83,6 +83,25 @@ class FileTransferModule(_ModuleBase): def init_setting(self) -> Tuple[str, Union[str, bool]]: pass + def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]: + """ + 获取重命名后的名称 + :param meta: 元数据 + :param mediainfo: 媒体信息 + :return: 重命名后的名称(含目录) + """ + # 重命名格式 + rename_format = settings.TV_RENAME_FORMAT \ + if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT + # 获取重命名后的名称 + path = self.get_rename_path( + template_string=rename_format, + rename_dict=self.__get_naming_dict(meta=meta, + mediainfo=mediainfo, + file_ext=Path(meta.title).suffix) + ) + return str(path) + def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo, transfer_type: str, target: Path = None, episodes_info: List[TmdbEpisode] = None, diff --git a/app/modules/indexer/mtorrent.py b/app/modules/indexer/mtorrent.py index 4d2a2a30..62346c69 100644 --- a/app/modules/indexer/mtorrent.py +++ b/app/modules/indexer/mtorrent.py @@ -19,13 +19,14 @@ class MTorrentSpider: """ _indexerid = None _domain = None + _url = None _name = "" _proxy = None _cookie = None _ua = None _size = 100 - _searchurl = "%sapi/torrent/search" - _downloadurl = "%sapi/torrent/genDlToken" + _searchurl = "https://api.%s/api/torrent/search" + _downloadurl = "https://api.%s/api/torrent/genDlToken" _pageurl = "%sdetail/%s" _timeout = 15 @@ -54,7 +55,8 @@ class MTorrentSpider: self.systemconfig = SystemConfigOper() if indexer: self._indexerid = indexer.get('id') - self._domain = indexer.get('domain') + self._url = indexer.get('domain') + self._domain = StringUtils.get_url_domain(self._url) self._searchurl = self._searchurl % self._domain self._name = indexer.get('name') if indexer.get('proxy'): @@ -124,7 +126,7 @@ class MTorrentSpider: 'grabs': int(result.get('status', {}).get("timesCompleted") or '0'), 'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('status', {}).get("discount")), 'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('status', {}).get("discount")), - 'page_url': self._pageurl % (self._domain, result.get('id')), + 'page_url': self._pageurl % (self._url, result.get('id')), 'imdbid': self.__find_imdbid(result.get('imdb')), 'labels': labels, 'category': category diff --git a/app/schemas/file.py b/app/schemas/file.py index b7773178..bf3badd2 100644 --- a/app/schemas/file.py +++ b/app/schemas/file.py @@ -20,3 +20,11 @@ class FileItem(BaseModel): modify_time: Optional[float] = None # 子节点 children: Optional[list] = [] + # ID + fileid: Optional[str] = None + # 父ID + parent_fileid: Optional[str] = None + # 缩略图 + thumbnail: Optional[str] = None + # 115 pickcode + pickcode: Optional[str] = None diff --git a/app/schemas/types.py b/app/schemas/types.py index e2fbba9d..625e27d4 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -94,6 +94,10 @@ class SystemConfigKey(Enum): DownloadDirectories = "DownloadDirectories" # 媒体库目录定义 LibraryDirectories = "LibraryDirectories" + # 阿里云盘认证参数 + UserAliyunParams = "UserAliyunParams" + # 115网盘认证参数 + User115Params = "User115Params" # 处理进度Key字典 @@ -102,6 +106,8 @@ class ProgressKey(Enum): Search = "search" # 转移 FileTransfer = "filetransfer" + # 批量重命名 + BatchRename = "batchrename" # 媒体图片类型 diff --git a/app/utils/string.py b/app/utils/string.py index bc708dd1..9203713c 100644 --- a/app/utils/string.py +++ b/app/utils/string.py @@ -383,6 +383,21 @@ class StringUtils: print(str(e)) return timestamp + @staticmethod + def str_to_timestamp(date_str: str) -> float: + """ + 日期转时间戳 + :param date_str: + :return: + """ + if not date_str: + return 0 + try: + return dateparser.parse(date_str).timestamp() + except Exception as e: + print(str(e)) + return 0 + @staticmethod def to_bool(text: str, default_val: bool = False) -> bool: """ diff --git a/requirements.txt b/requirements.txt index 71cbdd64..c7c321d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,3 +58,4 @@ pystray~=0.19.5 pyotp~=2.9.0 Pinyin2Hanzi~=0.1.1 pywebpush~=2.0.0 + py115~=0.0.4 \ No newline at end of file