diff --git a/Dockerfile b/Dockerfile index 01dd3817..e733edf4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ RUN apt-get update -y \ rsync \ ffmpeg \ nano \ + aria2 \ && \ if [ "$(uname -m)" = "x86_64" ]; \ then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \ diff --git a/app/chain/storage.py b/app/chain/storage.py index d6bb46b6..6244a0e6 100644 --- a/app/chain/storage.py +++ b/app/chain/storage.py @@ -40,11 +40,11 @@ class StorageChain(ChainBase): """ return self.run_module("create_folder", fileitem=fileitem, name=name) - def download_file(self, fileitem: schemas.FileItem) -> Optional[Path]: + def download_file(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 下载文件 """ - return self.run_module("download_file", fileitem=fileitem) + return self.run_module("download_file", fileitem=fileitem, path=path) def upload_file(self, fileitem: schemas.FileItem, path: Path) -> Optional[bool]: """ diff --git a/app/modules/filemanager/__init__.py b/app/modules/filemanager/__init__.py index 48a19c23..24e43086 100644 --- a/app/modules/filemanager/__init__.py +++ b/app/modules/filemanager/__init__.py @@ -206,7 +206,7 @@ class FileManagerModule(_ModuleBase): return False return storage_oper.rename(fileitem, name) - def download_file(self, fileitem: FileItem) -> Optional[Path]: + def download_file(self, fileitem: FileItem, path: Path = None) -> Optional[Path]: """ 下载文件 """ @@ -214,7 +214,7 @@ class FileManagerModule(_ModuleBase): if not storage_oper: logger.error(f"不支持 {fileitem.storage} 的下载处理") return None - return storage_oper.download(fileitem) + return storage_oper.download(fileitem, path=path) def upload_file(self, fileitem: FileItem, path: Path) -> Optional[FileItem]: """ @@ -449,7 +449,7 @@ class FileManagerModule(_ModuleBase): # 网盘到本地 if transfer_type in ["copy", "move"]: # 下载 - tmp_file = source_oper.download(fileitem) + tmp_file = source_oper.download(fileitem=fileitem, path=target_file.parent) if tmp_file: # 创建目录 if not target_file.parent.exists(): @@ -464,29 +464,25 @@ class FileManagerModule(_ModuleBase): return None, f"{fileitem.path} {fileitem.storage} 下载失败" elif fileitem.storage == target_storage: # 同一网盘 - if transfer_type == "move": - # 移动 - # 根据目的路径获取文件夹 - target_diritem = target_oper.get_folder(target_file.parent) - if target_diritem: - # 重命名文件 - if target_oper.rename(fileitem, target_file.name): - # 移动文件到新目录 - 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: - return None, f"{fileitem.path} {target_storage} 移动文件失败" + # 根据目的路径获取文件夹 + target_diritem = target_oper.get_folder(target_file.parent) + if target_diritem: + # 重命名文件 + if target_oper.rename(fileitem, target_file.name): + # 移动文件到新目录 + 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: - return None, f"{fileitem.path} {target_storage} 重命名文件失败" + return None, f"{fileitem.path} {target_storage} 移动文件失败" else: - return None, f"{target_file.parent} {target_storage} 目录获取失败" + return None, f"{fileitem.path} {target_storage} 重命名文件失败" else: - return None, f"网盘内只支持移动操作" + return None, f"{target_file.parent} {target_storage} 目录获取失败" return None, "未知错误" diff --git a/app/modules/filemanager/storages/__init__.py b/app/modules/filemanager/storages/__init__.py index 376cc6fd..2b045273 100644 --- a/app/modules/filemanager/storages/__init__.py +++ b/app/modules/filemanager/storages/__init__.py @@ -54,14 +54,14 @@ class StorageBase(metaclass=ABCMeta): pass @abstractmethod - def list(self, fileitm: schemas.FileItem) -> Optional[List[schemas.FileItem]]: + def list(self, fileitem: schemas.FileItem) -> Optional[List[schemas.FileItem]]: """ 浏览文件 """ pass @abstractmethod - def create_folder(self, fileitm: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: + def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: """ 创建目录 """ @@ -82,63 +82,63 @@ class StorageBase(metaclass=ABCMeta): pass @abstractmethod - def delete(self, fileitm: schemas.FileItem) -> bool: + def delete(self, fileitem: schemas.FileItem) -> bool: """ 删除文件 """ pass @abstractmethod - def rename(self, fileitm: schemas.FileItem, name: str) -> bool: + def rename(self, fileitem: schemas.FileItem, name: str) -> bool: """ 重命名文件 """ pass @abstractmethod - def download(self, fileitm: schemas.FileItem) -> Path: + def download(self, fileitem: schemas.FileItem, path: Path = None) -> Path: """ 下载文件,保存到本地,返回本地临时文件地址 """ pass @abstractmethod - def upload(self, fileitm: schemas.FileItem, path: Path) -> Optional[schemas.FileItem]: + def upload(self, fileitem: schemas.FileItem, path: Path) -> Optional[schemas.FileItem]: """ 上传文件 """ pass @abstractmethod - def detail(self, fileitm: schemas.FileItem) -> Optional[schemas.FileItem]: + def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件详情 """ pass @abstractmethod - def copy(self, fileitm: schemas.FileItem, target_file: Path) -> bool: + def copy(self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]) -> bool: """ 复制文件 """ pass @abstractmethod - def move(self, fileitm: schemas.FileItem, target: Union[schemas.FileItem, Path]) -> bool: + def move(self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]) -> bool: """ 移动文件 """ pass @abstractmethod - def link(self, fileitm: schemas.FileItem, target_file: Path) -> bool: + def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 硬链接文件 """ pass @abstractmethod - def softlink(self, fileitm: schemas.FileItem, target_file: Path) -> bool: + def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 软链接文件 """ diff --git a/app/modules/filemanager/storages/alipan.py b/app/modules/filemanager/storages/alipan.py index fd853403..7567ff8f 100644 --- a/app/modules/filemanager/storages/alipan.py +++ b/app/modules/filemanager/storages/alipan.py @@ -1,21 +1,20 @@ import base64 -import hashlib import json +import logging +import subprocess import time -import uuid from pathlib import Path from typing import Optional, Tuple, List -from requests import Response - from app import schemas from app.core.config import settings from app.log import logger from app.modules.filemanager.storages import StorageBase from app.schemas.types import StorageSchema from app.utils.http import RequestUtils +from aligo import Aligo, BaseFile + from app.utils.string import StringUtils -from app.utils.system import SystemUtils class AliPan(StorageBase): @@ -28,14 +27,14 @@ class AliPan(StorageBase): # 支持的整理方式 transtype = { - "move": "移动" + "move": "移动", } - _X_SIGNATURE = ('f4b7bed5d8524a04051bd2da876dd79afe922b8205226d65855d02b267422adb1' - 'e0d8a816b021eaf5c36d101892180f79df655c5712b348c2a540ca136e6b22001') + # 是否有aria2c + _has_aria2c: bool = False - _X_PUBLIC_KEY = ('04d9d2319e0480c840efeeb75751b86d0db0c5b9e72c6260a1d846958adceaf9d' - 'ee789cab7472741d23aafc1a9c591f72e7ee77578656e6c8588098dea1488ac2a') + # aligo + aligo: Aligo = None # 生成二维码 qrcode_url = ("https://passport.aliyundrive.com/newlogin/qrcode/generate.do?" @@ -43,62 +42,27 @@ class AliPan(StorageBase): "&lang=zh_CN&returnUrl=&bizParams=&_bx-v=2.0.31") # 二维码登录确认 check_url = "https://passport.aliyundrive.com/newlogin/qrcode/query.do?appName=aliyun_drive&fromSite=52&_bx-v=2.0.31" - # 更新访问令牌 - update_accessstoken_url = "https://auth.aliyundrive.com/v2/account/token" - # 创建会话 - create_session_url = "https://api.aliyundrive.com/users/v1/users/device/create_session" - # 用户信息 - user_info_url = "https://user.aliyundrive.com/v2/user/get" - # 浏览文件 - list_file_url = "https://api.aliyundrive.com/adrive/v3/file/list" - # 创建目录或文件 - create_folder_file_url = "https://api.aliyundrive.com/adrive/v2/file/createWithFolders" - # 文件详情 - file_detail_url = "https://api.aliyundrive.com/v2/file/get" - # 删除文件 - delete_file_url = " https://api.aliyundrive.com/v2/recyclebin/trash" - # 文件重命名 - rename_file_url = "https://api.aliyundrive.com/v3/file/update" - # 获取下载链接 - download_url = "https://api.aliyundrive.com/v2/file/get_download_url" - # 移动文件 - move_file_url = "https://api.aliyundrive.com/v2/file/move" - # 上传文件完成 - upload_file_complete_url = "https://api.aliyundrive.com/v2/file/complete" - # 查询存储详情 - storage_info_url = "https://api.aliyundrive.com/adrive/v1/user/driveCapacityDetails" - # 播放地址 - play_info_url = 'https://api.aliyundrive.com/v2/file/get_video_preview_play_info' - def __handle_error(self, res: Response, apiname: str, action: bool = True): - """ - 统一处理和打印错误信息 - """ - if res is None: - logger.warn("无法连接到阿里云盘!") - return + def __init__(self): + super().__init__() try: - result = res.json() - except Exception as err: - logger.error(f"解析阿里云盘返回数据失败:{str(err)}") - return - code = result.get("code") - message = result.get("message") - display_message = result.get("display_message") - if code or message: - logger.warn(f"Aliyun {apiname}失败:{code} - {display_message or message}") - if action: - if code == "DeviceSessionSignatureInvalid": - logger.warn("设备已失效,正在重新建立会话...") - self.__create_session(self.__get_headers(self.__auth_params)) - if code == "UserDeviceOffline": - logger.warn("设备已离线,尝试重新登录,如仍报错请检查阿里云盘绑定设备数量是否超限!") - self.__create_session(self.__get_headers(self.__auth_params)) - if code == "AccessTokenInvalid": - logger.warn("访问令牌已失效,正在刷新令牌...") - self.__update_accesstoken(self.__auth_params, self.__auth_params.get("refreshToken")) - else: - logger.info(f"Aliyun {apiname}成功") + subprocess.run(['aria2c', '-h'], capture_output=True) + self._has_aria2c = True + logger.debug('发现 aria2c, 将使用 aria2c 下载文件') + except FileNotFoundError: + logger.debug('未发现 aria2c') + self._has_aria2c = False + + self.__init_aligo() + + def __init_aligo(self): + """ + 初始化 aligo + """ + refresh_token = self.__auth_params.get("refreshToken") + if refresh_token: + self.aligo = Aligo(refresh_token=refresh_token, use_aria2=self._has_aria2c, + name="MoviePilot V2", level=logging.ERROR) @property def __auth_params(self): @@ -135,7 +99,6 @@ class AliPan(StorageBase): "t": data.get("t") }, "" elif res is not None: - self.__handle_error(res, "生成二维码") return {}, f"请求阿里云盘二维码失败:{res.status_code} - {res.reason}" return {}, f"请求阿里云盘二维码失败:无法连接!" @@ -194,151 +157,62 @@ class AliPan(StorageBase): "updateTime": time.time(), }) self.__update_params(data) - self.user_info() + self.__init_aligo() 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': 'MoviePilot', - 'modelName': __os_name(), - 'pubKey': self._X_PUBLIC_KEY, - }) - self.__handle_error(res, "创建会话", action=False) - - @property - def __access_params(self) -> Optional[dict]: - """ - 获取阿里云盘访问参数,如果超时则更新后返回 - """ - params = self.__auth_params - if not params: - logger.warn("阿里云盘访问令牌不存在,请先扫码登录!") - return None - expires_in = params.get("expiresIn") - update_time = params.get("updateTime") - refresh_token = params.get("refreshToken") - if not expires_in or not update_time or not refresh_token: - logger.warn("阿里云盘访问令牌参数错误,请重新扫码登录!") - self.__clear_params() - return None - # 是否需要更新设备信息 - update_device = False - # 判断访问令牌是否过期 - if (time.time() - update_time) >= expires_in: - logger.info("阿里云盘访问令牌已过期,正在更新...") - if not self.__update_accesstoken(params, refresh_token): - # 更新失败 - return None - update_device = True - # 生成设备ID - x_device_id = params.get("x_device_id") - if not x_device_id: - x_device_id = uuid.uuid4().hex - params['x_device_id'] = x_device_id - self.__update_params({"x_device_id": x_device_id}) - update_device = True - # 更新设备信息重新创建会话 - if update_device: - self.__create_session(self.__get_headers(params)) - return params - - def __get_headers(self, params: dict): - """ - 获取请求头 - """ - if not params: - return {} - return { - "Authorization": f"Bearer {params.get('accessToken')}", - "Content-Type": "application/json;charset=UTF-8", - "Accept": "application/json, text/plain, */*", - "Referer": "https://www.alipan.com/", - "User-Agent": settings.USER_AGENT, - "X-Canary": "client=web,app=adrive,version=v4.9.0", - "x-device-id": params.get('x_device_id'), - "x-signature": self._X_SIGNATURE - } - def check(self) -> bool: """ 检查存储是否可用 """ - params = self.__access_params - if not params: + if not self.aligo: return False - return True if self.list(schemas.FileItem( - fileid="root", - drive_id=params.get("resourceDriveId") - )) else False + return True if self.aligo.get_user() else False def user_info(self) -> dict: """ 获取用户信息(drive_id等) """ - params = self.__access_params - if not params: - return {} - headers = self.__get_headers(params) - res = RequestUtils(headers=headers, timeout=10).post_res(self.user_info_url) - if res: - result = res.json() - self.__update_params({ - "resourceDriveId": result.get("resource_drive_id"), - "backDriveId": result.get("backup_drive_id") - }) - return result + return self.aligo.get_user() + + def __get_fileitem(self, fileinfo: BaseFile, parent: str = "/") -> schemas.FileItem: + """ + 获取文件信息 + """ + if not fileinfo: + return schemas.FileItem() + if fileinfo.type == "folder": + return schemas.FileItem( + storage=self.schema.value, + fileid=fileinfo.file_id, + parent_fileid=fileinfo.parent_file_id, + type="dir", + path=f"{parent}{fileinfo.name}" + "/", + name=fileinfo.name, + basename=fileinfo.name, + size=fileinfo.size, + modify_time=StringUtils.str_to_timestamp(fileinfo.updated_at), + drive_id=fileinfo.drive_id, + ) else: - self.__handle_error(res, "获取用户信息") - return {} + return schemas.FileItem( + storage=self.schema.value, + fileid=fileinfo.file_id, + parent_fileid=fileinfo.parent_file_id, + type="file", + path=f"{parent}{fileinfo.name}", + name=fileinfo.name, + basename=Path(fileinfo.name).stem, + size=fileinfo.size, + extension=fileinfo.file_extension, + modify_time=StringUtils.str_to_timestamp(fileinfo.updated_at), + thumbnail=fileinfo.thumbnail, + drive_id=fileinfo.drive_id, + ) def list(self, fileitem: schemas.FileItem = None) -> List[schemas.FileItem]: """ @@ -348,18 +222,15 @@ class AliPan(StorageBase): parent_file_id 根目录为root type all | file | folder """ - params = self.__access_params - if not params: + if not self.aligo: return [] - # 请求头 - headers = self.__get_headers(params) # 根目录处理 if not fileitem or not fileitem.drive_id: return [ schemas.FileItem( storage=self.schema.value, fileid=fileitem.fileid, - drive_id=params.get("resourceDriveId"), + drive_id=self.__auth_params.get("resourceDriveId"), parent_fileid="root", type="dir", path="/资源库/", @@ -369,7 +240,7 @@ class AliPan(StorageBase): schemas.FileItem( storage=self.schema.value, fileid=fileitem.fileid, - drive_id=params.get("backDriveId"), + drive_id=self.__auth_params.get("backDriveId"), parent_fileid="root", type="dir", path="/备份盘/", @@ -377,436 +248,126 @@ class AliPan(StorageBase): basename="备份盘" ) ] - # 如果本身是文件 - if fileitem.type == "file": - return [fileitem] - # 返回数据 - ret_items = [] - # 分页获取 - next_marker = None - while True: - if not fileitem.parent_fileid or fileitem.parent_fileid == "/": - parent_file_id = "root" - else: - parent_file_id = fileitem.fileid - res = RequestUtils(headers=headers, timeout=10).post_res(self.list_file_url, json={ - "drive_id": fileitem.drive_id, - "parent_file_id": parent_file_id, - "marker": next_marker - }, params={ - 'jsonmask': ('next_marker,items(name,file_id,drive_id,type,size,created_at,updated_at,' - 'category,file_extension,parent_file_id,mime_type,starred,thumbnail,url,' - 'streams_info,content_hash,user_tags,user_meta,trashed,video_media_metadata,' - 'video_preview_metadata,sync_meta,sync_device_flag,sync_flag,punish_flag') - }) - if res: - result = res.json() - items = result.get("items") - if not items: - break - # 合并数据 - ret_items.extend(items) - next_marker = result.get("next_marker") - if not next_marker: - # 没有下一页 - break - else: - self.__handle_error(res, "浏览文件") - break - return [schemas.FileItem( - storage=self.schema.value, - fileid=fileinfo.get("file_id"), - parent_fileid=fileinfo.get("parent_file_id"), - type="dir" if fileinfo.get("type") == "folder" else "file", - path=f"{fileitem.path}{fileinfo.get('name')}" + ("/" if fileinfo.get("type") == "folder" else ""), - name=fileinfo.get("name"), - basename=Path(fileinfo.get("name")).stem, - size=fileinfo.get("size"), - extension=fileinfo.get("file_extension"), - modify_time=StringUtils.str_to_timestamp(fileinfo.get("updated_at")), - thumbnail=fileinfo.get("thumbnail"), - drive_id=fileinfo.get("drive_id"), - ) for fileinfo in ret_items] + elif fileitem.type == "file": + # 文件处理 + file = self.detail(fileitem) + if file: + return [file] + else: + items = self.aligo.get_file_list(parent_file_id=fileitem.fileid, drive_id=fileitem.drive_id) + if items: + return [self.__get_fileitem(item) for item in items] + return [] def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: """ 创建目录 """ - params = self.__access_params - if not params: + if not self.aligo: return None - headers = self.__get_headers(params) - res = RequestUtils(headers=headers, timeout=10).post_res(self.create_folder_file_url, json={ - "drive_id": fileitem.drive_id, - "parent_file_id": fileitem.parent_fileid, - "name": name, - "check_name_mode": "refuse", - "type": "folder" - }) - if res: - """ - { - "parent_file_id": "root", - "type": "folder", - "file_id": "6673f2c8a88344741bd64ad192d7512b92087719", - "domain_id": "bj29", - "drive_id": "39146740", - "file_name": "test", - "encrypt_mode": "none" - } - """ - result = res.json() - return schemas.FileItem( - storage=self.schema.value, - fileid=result.get("file_id"), - drive_id=result.get("drive_id"), - parent_fileid=result.get("parent_file_id"), - type=result.get("type"), - name=result.get("file_name"), - path=f"{fileitem.path}{result.get('file_name')}", - ) - else: - self.__handle_error(res, "创建目录") + item = self.aligo.create_folder(name=name, parent_file_id=fileitem.fileid, drive_id=fileitem.drive_id) + if item: + return self.__get_fileitem(item) return None def get_folder(self, path: Path) -> Optional[schemas.FileItem]: """ 根据文件路程获取目录,不存在则创建 """ - - 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 + if not self.aligo: return None - - # 逐级查找和创建目录 - fileitem = schemas.FileItem(fileid="root") - 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(dir_file, part) - if not dir_file: - logger.warn(f"{self.schema.value}创建目录 {fileitem.path}{part} 失败!") - return None - fileitem = dir_file - return fileitem + item = self.aligo.get_folder_by_path(path=str(Path), create_folder=True) + if item: + return self.__get_fileitem(item) + return None def get_item(self, path: Path) -> Optional[schemas.FileItem]: """ 获取文件或目录,不存在返回None """ - - 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 + if not self.aligo: return None - - # 逐级查找和创建目录 - fileitem = schemas.FileItem(fileid="root") - for part in path.parts: - if part == "/": - continue - item = __find_item(fileitem, part) - if not item: - return None - fileitem = item - return fileitem + item = self.aligo.get_file_by_path(path=str(path)) + if item: + return self.__get_fileitem(item) + return None def delete(self, fileitem: schemas.FileItem) -> bool: """ 删除文件 """ - params = self.__access_params - if not params: + if not self.aligo: return False - headers = self.__get_headers(params) - res = RequestUtils(headers=headers, timeout=10).post_res(self.delete_file_url, json={ - "drive_id": fileitem.drive_id, - "file_id": fileitem.fileid - }) - if res: + if self.aligo.move_file_to_trash(file_id=fileitem.fileid, drive_id=fileitem.drive_id): return True - else: - self.__handle_error(res, "删除文件") return False def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件详情 """ - params = self.__access_params - if not params: + if not self.aligo: return None - headers = self.__get_headers(params) - res = RequestUtils(headers=headers, timeout=10).post_res(self.file_detail_url, json={ - "drive_id": fileitem.drive_id, - "file_id": fileitem.fileid - }) - if res: - result = res.json() - return schemas.FileItem( - storage=self.schema.value, - fileid=result.get("file_id"), - drive_id=result.get("drive_id"), - parent_fileid=result.get("parent_file_id"), - type="file", - name=result.get("name"), - size=result.get("size"), - extension=result.get("file_extension"), - modify_time=StringUtils.str_to_timestamp(result.get("updated_at")), - thumbnail=result.get("thumbnail"), - path=f"{fileitem.path}{result.get('name')}", - url=result.get("download_url") or result.get("url") - ) - else: - self.__handle_error(res, "获取文件详情") + item = self.aligo.get_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id) + if item: + return self.__get_fileitem(item) return None def rename(self, fileitem: schemas.FileItem, name: str) -> bool: """ 重命名文件 """ - params = self.__access_params - if not params: + if not self.aligo: return False - headers = self.__get_headers(params) - res = RequestUtils(headers=headers, timeout=10).post_res(self.rename_file_url, json={ - "drive_id": fileitem.drive_id, - "file_id": fileitem.fileid, - "name": name, - "check_name_mode": "refuse" - }) - if res: + if self.aligo.rename_file(file_id=fileitem.fileid, name=name, drive_id=fileitem.drive_id): return True - else: - self.__handle_error(res, "重命名文件") return False - def download(self, fileitem: schemas.FileItem) -> Optional[Path]: + def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 下载文件,保存到本地 """ - params = self.__access_params - if not params: + if not self.aligo: return None - headers = self.__get_headers(params) - - def __get_play_url(): - """ - 获取播放地址 - """ - play_res = RequestUtils(headers=headers, timeout=10).post_res(self.play_info_url, json={ - "drive_id": fileitem.drive_id, - "file_id": fileitem.fileid - }) - if play_res: - play_dict = {} - play_info = play_res.json() - if play_info.get('video_preview_play_info'): - for i in play_info['video_preview_play_info'].get('live_transcoding_task_list') or []: - if i.get('url'): - try: - play_dict[i['template_id']] = i['url'] - except KeyError: - pass - if play_dict: - return list(play_dict.values())[-1] - return None - - # 先获取文件详情 - fileinfo = self.detail(fileitem) - if not fileinfo: - logger.warn(f"{fileitem.path} 文件不存在") - return None - - # 文件下载链接 - download_url = None - if fileinfo.url: - # 使用文件详情中的链接 - download_url = fileinfo.url - else: - # 查询文件下载链接 - res = RequestUtils(headers=headers, timeout=10).post_res(self.download_url, json={ - "drive_id": fileitem.drive_id, - "file_id": fileitem.fileid, - "file_name": fileitem.name, - }) - if res: - result = res.json() - download_url = result.get("url") or result.get("internal_url") - - if not download_url: - # 查询播放链接 - download_url = __get_play_url() - - if not download_url: - logger.warn(f"{fileitem.path} 未获取到下载链接") - return None - # 下载文件到本地 - res = RequestUtils(headers={ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Referer": "https://www.alipan.com/", - "Sec-Fetch-Dest": "iframe", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "cross-site", - "User-Agent": settings.USER_AGENT - }).get_res(download_url) - if res: - path = settings.TEMP_PATH / fileitem.name - with path.open("wb") as f: - f.write(res.content) - return path - else: - self.__handle_error(res, "获取下载链接") + local_path = self.aligo.download_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id, + local_folder=str(path or settings.TEMP_PATH)) + if local_path: + return Path(local_path) return None def upload(self, fileitem: schemas.FileItem, path: Path) -> Optional[schemas.FileItem]: """ 上传文件,并标记完成 """ - - __UPLOAD_CHUNK_SIZE: int = 10485760 # 10 MB - - def __sha1(_path: Path): - """ - 计算文件sha1,用于快传 - """ - _sha1 = hashlib.sha1() - with open(_path, 'rb') as f: - while True: - data = f.read(8192) - if not data: - break - _sha1.update(data) - return _sha1.hexdigest() - - params = self.__access_params - if not params: + if not self.aligo: return None - headers = self.__get_headers(params) - - # 计算sha1 - sha1 = __sha1(path) - res = RequestUtils(headers=headers, timeout=10).post_res(self.create_folder_file_url, json={ - "drive_id": fileitem.drive_id, - "parent_file_id": fileitem.parent_fileid, - "name": path.name, - "check_name_mode": "refuse", - "create_scene": "file_upload", - "type": "file", - "content_hash": sha1, - "content_hash_name": "sha1", - "part_info_list": [ - { - "part_number": 1 - } - ], - "size": path.stat().st_size - }) - if not res: - self.__handle_error(res, "创建文件") - return None - # 获取上传请求结果 - result = res.json() - if result.get("exist") or result.get("rapid_upload"): - # 已存在 - logger.info(f"文件 {result.get('file_name')} 已存在或已秒传完成,无需上传") - return schemas.FileItem( - storage=self.schema.value, - drive_id=result.get("drive_id"), - fileid=result.get("file_id"), - parent_fileid=result.get("parent_file_id"), - type="file", - name=result.get("file_name"), - path=f"{fileitem.path}{result.get('file_name')}" - ) - # 上传文件 - file_id = result.get("file_id") - upload_id = result.get("upload_id") - part_info_list = result.get("part_info_list") - if part_info_list: - # 上传地址 - upload_url = part_info_list[0].get("upload_url") - # 上传文件 - res = RequestUtils(headers={ - "Content-Type": "", - "User-Agent": settings.USER_AGENT, - "Referer": "https://www.alipan.com/", - "Accept": "*/*", - }).put_res(upload_url, data=path.read_bytes()) - if not res: - self.__handle_error(res, "上传文件") - return None - # 标记文件上传完毕 - res = RequestUtils(headers=headers, timeout=10).post_res(self.upload_file_complete_url, json={ - "drive_id": fileitem.drive_id, - "file_id": file_id, - "upload_id": upload_id - }) - if not res: - self.__handle_error(res, "标记上传状态") - return None - result = res.json() - return schemas.FileItem( - storage=self.schema.value, - fileid=result.get("file_id"), - drive_id=result.get("drive_id"), - parent_fileid=result.get("parent_file_id"), - type="file", - name=result.get("name"), - path=f"{fileitem.path}{result.get('name')}", - ) - else: - logger.warn("阿里云盘上传文件失败:无法获取上传地址!") + item = self.aligo.upload_file(file_path=str(path.parent), parent_file_id=fileitem.fileid, + drive_id=fileitem.drive_id, name=path.name) + if item: + return self.__get_fileitem(item) return None def move(self, fileitem: schemas.FileItem, target: schemas.FileItem) -> bool: """ 移动文件 """ - params = self.__access_params - if not params: + if not self.aligo: return False - headers = self.__get_headers(params) - res = RequestUtils(headers=headers, timeout=10).post_res(self.move_file_url, json={ - "drive_id": fileitem.drive_id, - "file_id": fileitem.fileid, - "to_parent_file_id": target.fileid, - "check_name_mode": "refuse" - }) - if res: + 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): return True - else: - self.__handle_error(res, "移动文件") return False - def copy(self, fileitm: schemas.FileItem, target_file: Path) -> bool: - """ - 复制文件 - """ + def copy(self, fileitem: schemas.FileItem, target: schemas.FileItem) -> bool: pass - def link(self, fileitm: schemas.FileItem, target_file: Path) -> bool: + def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 硬链接文件 """ pass - def softlink(self, fileitm: schemas.FileItem, target_file: Path) -> bool: + def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 软链接文件 """ @@ -816,17 +377,14 @@ class AliPan(StorageBase): """ 存储使用情况 """ - params = self.__access_params - if not params: + if not self.aligo: return None - headers = self.__get_headers(params) - res = RequestUtils(headers=headers, timeout=10).post_res(self.storage_info_url, json={}) - if res: - result = res.json() - return schemas.StorageUsage( - total=result.get("drive_total_size"), - available=result.get("drive_total_size") - result.get("drive_used_size") - ) - else: - self.__handle_error(res, "查询存储详情") + user_capacity = self.aligo.get_user_capacity_info() + if user_capacity: + drive_capacity = user_capacity.drive_capacity_details + if drive_capacity: + return schemas.StorageUsage( + total=drive_capacity.drive_total_size, + available=drive_capacity.drive_total_size - drive_capacity.drive_used_size + ) return None diff --git a/app/modules/filemanager/storages/local.py b/app/modules/filemanager/storages/local.py index c9333be5..e188e294 100644 --- a/app/modules/filemanager/storages/local.py +++ b/app/modules/filemanager/storages/local.py @@ -135,11 +135,11 @@ class LocalStorage(StorageBase): return self.__get_fileitem(path) return self.__get_diritem(path) - def detail(self, fileitm: schemas.FileItem) -> Optional[schemas.FileItem]: + def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件详情 """ - path_obj = Path(fileitm.path) + path_obj = Path(fileitem.path) if not path_obj.exists(): return None return self.__get_fileitem(path_obj) @@ -177,7 +177,7 @@ class LocalStorage(StorageBase): return False return True - def download(self, fileitem: schemas.FileItem) -> Optional[Path]: + def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 下载文件 """ diff --git a/app/modules/filemanager/storages/rclone.py b/app/modules/filemanager/storages/rclone.py index fc95e9bf..64d1365a 100644 --- a/app/modules/filemanager/storages/rclone.py +++ b/app/modules/filemanager/storages/rclone.py @@ -118,7 +118,7 @@ class Rclone(StorageBase): logger.error(f"rclone浏览文件失败:{err}") return [] - def create_folder(self, fileitm: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: + def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: """ 创建目录 """ @@ -126,13 +126,13 @@ class Rclone(StorageBase): retcode = subprocess.run( [ 'rclone', 'mkdir', - f'MP:{fileitm.path}/{name}' + f'MP:{fileitem.path}/{name}' ], startupinfo=self.__get_hidden_shell() ).returncode if retcode == 0: - ret_fileitem = copy.deepcopy(fileitm) - ret_fileitem.path = f"{fileitm.path}/{name}/" + ret_fileitem = copy.deepcopy(fileitem) + ret_fileitem.path = f"{fileitem.path}/{name}/" ret_fileitem.name = name return ret_fileitem except Exception as err: @@ -191,7 +191,7 @@ class Rclone(StorageBase): logger.error(f"rclone获取文件失败:{err}") return None - def delete(self, fileitm: schemas.FileItem) -> bool: + def delete(self, fileitem: schemas.FileItem) -> bool: """ 删除文件 """ @@ -199,7 +199,7 @@ class Rclone(StorageBase): retcode = subprocess.run( [ 'rclone', 'deletefile', - f'MP:{fileitm.path}' + f'MP:{fileitem.path}' ], startupinfo=self.__get_hidden_shell() ).returncode @@ -209,7 +209,7 @@ class Rclone(StorageBase): logger.error(f"rclone删除文件失败:{err}") return False - def rename(self, fileitm: schemas.FileItem, name: str) -> bool: + def rename(self, fileitem: schemas.FileItem, name: str) -> bool: """ 重命名文件 """ @@ -217,8 +217,8 @@ class Rclone(StorageBase): retcode = subprocess.run( [ 'rclone', 'moveto', - f'MP:{fileitm.path}', - f'MP:{Path(fileitm.path).parent}/{name}' + f'MP:{fileitem.path}', + f'MP:{Path(fileitem.path).parent}/{name}' ], startupinfo=self.__get_hidden_shell() ).returncode @@ -228,11 +228,11 @@ class Rclone(StorageBase): logger.error(f"rclone重命名文件失败:{err}") return False - def download(self, fileitem: schemas.FileItem) -> Optional[Path]: + def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 下载文件 """ - path = settings.TEMP_PATH / fileitem.name + path = (path or settings.TEMP_PATH) / fileitem.name try: retcode = subprocess.run( [ @@ -248,7 +248,7 @@ class Rclone(StorageBase): logger.error(f"rclone复制文件失败:{err}") return None - def upload(self, fileitm: schemas.FileItem, path: Path) -> Optional[schemas.FileItem]: + def upload(self, fileitem: schemas.FileItem, path: Path) -> Optional[schemas.FileItem]: """ 上传文件 """ @@ -256,7 +256,7 @@ class Rclone(StorageBase): retcode = subprocess.run( [ 'rclone', 'copyto', - fileitm.path, + fileitem.path, f'MP:{path}' ], startupinfo=self.__get_hidden_shell() @@ -267,7 +267,7 @@ class Rclone(StorageBase): logger.error(f"rclone上传文件失败:{err}") return None - def detail(self, fileitm: schemas.FileItem) -> Optional[schemas.FileItem]: + def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件详情 """ @@ -275,7 +275,7 @@ class Rclone(StorageBase): ret = subprocess.run( [ 'rclone', 'lsjson', - f'MP:{fileitm.path}' + f'MP:{fileitem.path}' ], capture_output=True, startupinfo=self.__get_hidden_shell() @@ -287,7 +287,7 @@ class Rclone(StorageBase): logger.error(f"rclone获取文件详情失败:{err}") return None - def move(self, fileitm: schemas.FileItem, target: Path) -> bool: + def move(self, fileitem: schemas.FileItem, target: Path) -> bool: """ 移动文件,target_file格式:rclone:path """ @@ -295,7 +295,7 @@ class Rclone(StorageBase): retcode = subprocess.run( [ 'rclone', 'moveto', - f'MP:{fileitm.path}', + f'MP:{fileitem.path}', f'MP:{target}' ], startupinfo=self.__get_hidden_shell() @@ -306,13 +306,13 @@ class Rclone(StorageBase): logger.error(f"rclone移动文件失败:{err}") return False - def copy(self, fileitm: schemas.FileItem, target_file: Path) -> bool: + def copy(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass - def link(self, fileitm: schemas.FileItem, target_file: Path) -> bool: + def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass - def softlink(self, fileitm: schemas.FileItem, target_file: Path) -> bool: + def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def usage(self) -> Optional[schemas.StorageUsage]: diff --git a/app/modules/filemanager/storages/u115.py b/app/modules/filemanager/storages/u115.py index 8030165d..f5fbd76d 100644 --- a/app/modules/filemanager/storages/u115.py +++ b/app/modules/filemanager/storages/u115.py @@ -1,4 +1,5 @@ import base64 +import subprocess from pathlib import Path from typing import Optional, Tuple, List @@ -32,6 +33,19 @@ class U115Pan(StorageBase, metaclass=Singleton): cloud: Optional[Cloud] = None _session: QrcodeSession = None + # 是否有aria2c + _has_aria2c: bool = False + + def __init__(self): + super().__init__() + try: + 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: """ 初始化Cloud @@ -260,7 +274,7 @@ class U115Pan(StorageBase, metaclass=Singleton): fileitem = item return fileitem - def detail(self, fileitm: schemas.FileItem) -> Optional[schemas.FileItem]: + def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件详情 """ @@ -292,7 +306,7 @@ class U115Pan(StorageBase, metaclass=Singleton): logger.error(f"115重命名文件失败:{str(e)}") return False - def download(self, fileitem: schemas.FileItem) -> Optional[Path]: + def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 获取下载链接 """ @@ -301,7 +315,7 @@ class U115Pan(StorageBase, metaclass=Singleton): try: ticket = self.cloud.storage().request_download(fileitem.pickcode) if ticket: - path = settings.TEMP_PATH / fileitem.name + path = (path or settings.TEMP_PATH) / fileitem.name res = RequestUtils(headers=ticket.headers).get_res(ticket.url) if res: with open(path, "wb") as f: @@ -374,13 +388,13 @@ class U115Pan(StorageBase, metaclass=Singleton): logger.error(f"115移动文件失败:{str(e)}") return False - def copy(self, fileitm: schemas.FileItem, target_file: Path) -> bool: + def copy(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass - def link(self, fileitm: schemas.FileItem, target_file: Path) -> bool: + def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass - def softlink(self, fileitm: schemas.FileItem, target_file: Path) -> bool: + def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def usage(self) -> Optional[schemas.StorageUsage]: