mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-31 04:57:23 +08:00
1010 lines
34 KiB
Python
1010 lines
34 KiB
Python
import hashlib
|
|
import json
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional, List
|
|
|
|
from app.schemas.file import StorageUsage as _SchemaStorageUsage
|
|
from app.schemas.workflow import FileItem as _SchemaFileItem
|
|
from app.runtime.cache import cached
|
|
from app.runtime.settings import RuntimeSettingsCompat
|
|
from app.runtime.config import global_vars
|
|
|
|
settings = RuntimeSettingsCompat()
|
|
from app.runtime.log import logger
|
|
from app.modules.filemanager.storages import StorageBase, transfer_process
|
|
from app.schemas.exception import OperationInterrupted, StorageQueryError
|
|
from app.schemas.types import StorageSchema
|
|
from app.adapters.network.http import RequestUtils
|
|
from app.foundation.singleton import WeakSingleton
|
|
from app.foundation.url import UrlUtils
|
|
|
|
|
|
# OpenList/AList 在 per_page<=0 时会退回后端默认 200,显式指定最大页大小避免大目录被截断。
|
|
OPENLIST_MAX_LIST_PAGE_SIZE = 500
|
|
|
|
|
|
class Alist(StorageBase, metaclass=WeakSingleton):
|
|
"""
|
|
Openlist相关操作
|
|
|
|
API 文档:https://fox.oplist.org/
|
|
"""
|
|
|
|
# 存储类型
|
|
schema = StorageSchema.Alist
|
|
|
|
# 支持的整理方式
|
|
transtype = {
|
|
"copy": "复制",
|
|
"move": "移动",
|
|
}
|
|
|
|
# 快照检查目录修改时间
|
|
snapshot_check_folder_modtime = settings.OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
def init_storage(self):
|
|
"""
|
|
初始化
|
|
"""
|
|
conf = self.get_conf()
|
|
self.__login_token.cache_delete( # noqa
|
|
self, self.__get_base_url, conf.get("username"), conf.get("password")
|
|
)
|
|
|
|
def _delay_get_item(
|
|
self, path: Path, /, refresh: bool = False
|
|
) -> Optional[_SchemaFileItem]:
|
|
"""
|
|
自动延迟重试 get_item 模块
|
|
|
|
:param path: 文件路径
|
|
:param refresh: 是否刷新
|
|
:return: 文件项
|
|
"""
|
|
for _ in range(2):
|
|
time.sleep(2)
|
|
fileitem = self.get_item(path=path, refresh=refresh)
|
|
if fileitem:
|
|
return fileitem
|
|
return None
|
|
|
|
def __build_transfer_item(
|
|
self, source_item: _SchemaFileItem, target_path: Path
|
|
) -> _SchemaFileItem:
|
|
"""
|
|
根据目标路径构造文件项,用于 OpenList 操作成功但元数据短时间不可见的场景。
|
|
目录项路径需要遵循 FileItem 以斜杠结尾的约定。
|
|
"""
|
|
target_path_str = target_path.as_posix()
|
|
if source_item.type == "dir" and not target_path_str.endswith("/"):
|
|
target_path_str = f"{target_path_str}/"
|
|
|
|
return _SchemaFileItem(
|
|
storage=self.schema.value,
|
|
type=source_item.type,
|
|
path=target_path_str,
|
|
name=target_path.name,
|
|
basename=target_path.stem,
|
|
extension=target_path.suffix[1:] if source_item.type != "dir" else None,
|
|
size=getattr(source_item, "size", None),
|
|
modify_time=getattr(source_item, "modify_time", None),
|
|
thumbnail=getattr(source_item, "thumbnail", None),
|
|
)
|
|
|
|
@property
|
|
def __get_base_url(self) -> str:
|
|
"""
|
|
获取基础URL
|
|
"""
|
|
url = self.get_conf().get("url")
|
|
if url is None:
|
|
return ""
|
|
return UrlUtils.standardize_base_url(self.get_conf().get("url"))
|
|
|
|
def __get_api_url(self, path: str) -> str:
|
|
"""
|
|
获取API URL
|
|
|
|
:param path: API路径
|
|
:return: API URL
|
|
"""
|
|
return UrlUtils.adapt_request_url(self.__get_base_url, path)
|
|
|
|
@property
|
|
def __get_valuable_toke(self) -> str:
|
|
"""
|
|
获取一个可用的token
|
|
如果设置永久令牌则返回永久令牌
|
|
否则使用账号密码生成临时令牌
|
|
"""
|
|
return self.__generate_token()
|
|
|
|
def __generate_token(self) -> str:
|
|
"""
|
|
如果设置永久令牌则返回永久令牌,否则使用账号密码生成一个临时 token
|
|
"""
|
|
conf = self.get_conf()
|
|
token = conf.get("token")
|
|
if token:
|
|
return str(token)
|
|
return self.__login_token(
|
|
self.__get_base_url, conf.get("username"), conf.get("password")
|
|
)
|
|
|
|
@cached(maxsize=8, ttl=60 * 60 * 24 * 2 - 60 * 5, skip_empty=True)
|
|
def __login_token(
|
|
self, base_url: str, username: Optional[str], password: Optional[str]
|
|
) -> str:
|
|
"""
|
|
使用账号密码生成一个临时 token
|
|
缓存2天,提前5分钟更新
|
|
"""
|
|
resp = RequestUtils(headers={"Content-Type": "application/json"}).post_res(
|
|
UrlUtils.adapt_request_url(base_url, "/api/auth/login"),
|
|
data=json.dumps(
|
|
{
|
|
"username": username,
|
|
"password": password,
|
|
}
|
|
),
|
|
)
|
|
"""
|
|
{
|
|
"username": "{{alist_username}}",
|
|
"password": "{{alist_password}}"
|
|
}
|
|
======================================
|
|
{
|
|
"code": 200,
|
|
"message": "success",
|
|
"data": {
|
|
"token": "abcd"
|
|
}
|
|
}
|
|
"""
|
|
|
|
if resp is None:
|
|
logger.warning("【OpenList】请求登录失败,无法连接alist服务")
|
|
return ""
|
|
|
|
if resp.status_code != 200:
|
|
logger.warning(
|
|
f"【OpenList】更新令牌请求发送失败,状态码:{resp.status_code}"
|
|
)
|
|
return ""
|
|
|
|
result = resp.json()
|
|
|
|
if result["code"] != 200:
|
|
logger.critical(f"【OpenList】更新令牌,错误信息:{result['message']}")
|
|
return ""
|
|
|
|
logger.debug("【OpenList】AList获取令牌成功")
|
|
return result["data"]["token"]
|
|
|
|
def __get_header_with_token(self) -> dict:
|
|
"""
|
|
获取带有token的header
|
|
"""
|
|
return {"Authorization": self.__get_valuable_toke}
|
|
|
|
def check(self) -> bool:
|
|
"""
|
|
检查存储是否可用
|
|
"""
|
|
return True if self.__generate_token() else False
|
|
|
|
def list(
|
|
self,
|
|
fileitem: _SchemaFileItem,
|
|
password: Optional[str] = "",
|
|
page: int = 1,
|
|
per_page: int = 0,
|
|
refresh: bool = False,
|
|
) -> List[_SchemaFileItem]:
|
|
"""
|
|
浏览文件
|
|
:param fileitem: 文件项
|
|
:param password: 路径密码
|
|
:param page: 页码
|
|
:param per_page: 每页数量
|
|
:param refresh: 是否刷新
|
|
:return: 文件列表
|
|
"""
|
|
if fileitem.type == "file":
|
|
item = self.get_item(Path(fileitem.path))
|
|
if item:
|
|
return [item]
|
|
return []
|
|
items = []
|
|
current_page = page
|
|
auto_page = per_page <= 0
|
|
effective_per_page = OPENLIST_MAX_LIST_PAGE_SIZE if auto_page else per_page
|
|
while True:
|
|
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
|
|
self.__get_api_url("/api/fs/list"),
|
|
json={
|
|
"path": fileitem.path,
|
|
"password": password,
|
|
"page": current_page,
|
|
"per_page": effective_per_page,
|
|
"refresh": refresh,
|
|
},
|
|
)
|
|
"""
|
|
{
|
|
"path": "/t",
|
|
"password": "",
|
|
"page": 1,
|
|
"per_page": 0,
|
|
"refresh": false
|
|
}
|
|
======================================
|
|
{
|
|
"code": 200,
|
|
"message": "success",
|
|
"data": {
|
|
"content": [
|
|
{
|
|
"name": "Alist V3.md",
|
|
"size": 1592,
|
|
"is_dir": false,
|
|
"modified": "2024-05-17T13:47:55.4174917+08:00",
|
|
"created": "2024-05-17T13:47:47.5725906+08:00",
|
|
"sign": "",
|
|
"thumb": "",
|
|
"type": 4,
|
|
"hashinfo": "null",
|
|
"hash_info": null
|
|
}
|
|
],
|
|
"total": 1,
|
|
"readme": "",
|
|
"header": "",
|
|
"write": true,
|
|
"provider": "Local"
|
|
}
|
|
}
|
|
"""
|
|
|
|
if resp is None:
|
|
logger.warn(
|
|
f"【OpenList】请求获取目录 {fileitem.path} 的文件列表失败,无法连接alist服务"
|
|
)
|
|
return []
|
|
if resp.status_code != 200:
|
|
logger.warn(
|
|
f"【OpenList】请求获取目录 {fileitem.path} 的文件列表失败,状态码:{resp.status_code}"
|
|
)
|
|
return []
|
|
|
|
result = resp.json()
|
|
|
|
if result["code"] != 200:
|
|
logger.warn(
|
|
f"【OpenList】获取目录 {fileitem.path} 的文件列表失败,错误信息:{result['message']}"
|
|
)
|
|
return []
|
|
|
|
page_data = result["data"]
|
|
page_content = page_data.get("content") or []
|
|
items.extend(
|
|
[
|
|
_SchemaFileItem(
|
|
storage=self.schema.value,
|
|
type="dir" if item["is_dir"] else "file",
|
|
path=(Path(fileitem.path) / item["name"]).as_posix()
|
|
+ ("/" if item["is_dir"] else ""),
|
|
name=item["name"],
|
|
basename=Path(item["name"]).stem,
|
|
extension=Path(item["name"]).suffix[1:] if not item["is_dir"] else None,
|
|
size=item["size"] if not item["is_dir"] else None,
|
|
modify_time=self.__parse_timestamp(item["modified"]),
|
|
thumbnail=item["thumb"],
|
|
)
|
|
for item in page_content
|
|
]
|
|
)
|
|
|
|
if not auto_page:
|
|
return items
|
|
|
|
total = page_data.get("filtered_total") or page_data.get("total") or 0
|
|
pages_total = page_data.get("pages_total") or 0
|
|
has_more = page_data.get("has_more")
|
|
if not page_content or len(items) >= total:
|
|
return items
|
|
if has_more is False or (pages_total and current_page >= pages_total):
|
|
return items
|
|
|
|
current_page += 1
|
|
|
|
def create_folder(
|
|
self, fileitem: _SchemaFileItem, name: str
|
|
) -> Optional[_SchemaFileItem]:
|
|
"""
|
|
创建目录
|
|
:param fileitem: 父目录
|
|
:param name: 目录名
|
|
:return: 目录项
|
|
"""
|
|
path = Path(fileitem.path) / name
|
|
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
|
|
self.__get_api_url("/api/fs/mkdir"),
|
|
json={"path": path.as_posix()},
|
|
)
|
|
"""
|
|
{
|
|
"path": "/tt"
|
|
}
|
|
======================================
|
|
{
|
|
"code": 200,
|
|
"message": "success",
|
|
"data": null
|
|
}
|
|
"""
|
|
if resp is None:
|
|
logger.warn(f"【OpenList】请求创建目录 {path} 失败,无法连接alist服务")
|
|
return None
|
|
if resp.status_code != 200:
|
|
logger.warn(
|
|
f"【OpenList】请求创建目录 {path} 失败,状态码:{resp.status_code}"
|
|
)
|
|
return None
|
|
|
|
result = resp.json()
|
|
if result["code"] != 200:
|
|
logger.warn(
|
|
f"【OpenList】创建目录 {path} 失败,错误信息:{result['message']}"
|
|
)
|
|
return None
|
|
|
|
return self._delay_get_item(
|
|
path, refresh=True
|
|
) or self.__build_transfer_item(
|
|
_SchemaFileItem(
|
|
storage=self.schema.value,
|
|
type="dir",
|
|
path=fileitem.path,
|
|
name=name,
|
|
basename=Path(name).stem,
|
|
),
|
|
path,
|
|
)
|
|
|
|
def get_folder(self, path: Path) -> Optional[_SchemaFileItem]:
|
|
"""
|
|
获取目录,如目录不存在则创建
|
|
|
|
:param path: 目录路径
|
|
:return: 目录项
|
|
"""
|
|
folder = self.get_item(path)
|
|
if folder:
|
|
return folder
|
|
if not folder:
|
|
folder = self.create_folder(
|
|
_SchemaFileItem(
|
|
storage=self.schema.value,
|
|
type="dir",
|
|
path=path.parent.as_posix(),
|
|
name=path.name,
|
|
basename=path.stem,
|
|
),
|
|
path.name,
|
|
)
|
|
return folder
|
|
|
|
def get_item(
|
|
self,
|
|
path: Path,
|
|
password: Optional[str] = "",
|
|
page: int = 1,
|
|
per_page: int = 0,
|
|
refresh: bool = False,
|
|
) -> Optional[_SchemaFileItem]:
|
|
"""
|
|
获取文件或目录,不存在返回None
|
|
:param path: 文件路径
|
|
:param password: 路径密码
|
|
:param page: 页码
|
|
:param per_page: 每页数量
|
|
:param refresh: 是否刷新
|
|
:return: 文件项
|
|
"""
|
|
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
|
|
self.__get_api_url("/api/fs/get"),
|
|
json={
|
|
"path": path.as_posix(),
|
|
"password": password,
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"refresh": refresh,
|
|
},
|
|
)
|
|
"""
|
|
{
|
|
"path": "/t",
|
|
"password": "",
|
|
"page": 1,
|
|
"per_page": 0,
|
|
"refresh": false
|
|
}
|
|
======================================
|
|
{
|
|
"code": 200,
|
|
"message": "success",
|
|
"data": {
|
|
"name": "Alist V3.md",
|
|
"size": 2618,
|
|
"is_dir": false,
|
|
"modified": "2024-05-17T16:05:36.4651534+08:00",
|
|
"created": "2024-05-17T16:05:29.2001008+08:00",
|
|
"sign": "",
|
|
"thumb": "",
|
|
"type": 4,
|
|
"hashinfo": "null",
|
|
"hash_info": null,
|
|
"raw_url": "http://127.0.0.1:5244/p/local/Alist%20V3.md",
|
|
"readme": "",
|
|
"header": "",
|
|
"provider": "Local",
|
|
"related": null
|
|
}
|
|
}
|
|
"""
|
|
if resp is None:
|
|
logger.warn(f"【OpenList】请求获取文件 {path} 失败,无法连接alist服务")
|
|
return None
|
|
if resp.status_code != 200:
|
|
logger.warn(
|
|
f"【OpenList】请求获取文件 {path} 失败,状态码:{resp.status_code}"
|
|
)
|
|
return None
|
|
|
|
result = resp.json()
|
|
if result["code"] != 200:
|
|
logger.debug(
|
|
f"【OpenList】获取文件 {path} 失败,错误信息:{result['message']}"
|
|
)
|
|
return None
|
|
|
|
return self.__build_fileitem(path, result["data"])
|
|
|
|
def __build_fileitem(self, path: Path, data: dict) -> _SchemaFileItem:
|
|
"""
|
|
根据接口返回数据构建文件项。
|
|
:param path: 文件路径
|
|
:param data: 接口返回的 data 字段
|
|
:return: 文件项
|
|
"""
|
|
return _SchemaFileItem(
|
|
storage=self.schema.value,
|
|
type="dir" if data["is_dir"] else "file",
|
|
path=path.as_posix() + ("/" if data["is_dir"] else ""),
|
|
name=data["name"],
|
|
basename=Path(data["name"]).stem,
|
|
extension=Path(data["name"]).suffix[1:],
|
|
size=data["size"],
|
|
modify_time=self.__parse_timestamp(data["modified"]),
|
|
thumbnail=data["thumb"],
|
|
)
|
|
|
|
def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]:
|
|
"""
|
|
获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。
|
|
只有接口明确回报「对象不存在」才是确定结果,连接失败、HTTP 异常与其他
|
|
业务错误都无法确认目标状态,必须保守失败以免覆盖保护被绕过。
|
|
"""
|
|
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
|
|
self.__get_api_url("/api/fs/get"),
|
|
json={
|
|
"path": path.as_posix(),
|
|
"password": "",
|
|
"page": 1,
|
|
"per_page": 0,
|
|
"refresh": False,
|
|
},
|
|
)
|
|
if resp is None:
|
|
raise StorageQueryError(f"【OpenList】查询文件 {path} 失败,无法连接服务")
|
|
if resp.status_code != 200:
|
|
raise StorageQueryError(f"【OpenList】查询文件 {path} 失败,状态码:{resp.status_code}")
|
|
try:
|
|
result = resp.json()
|
|
except Exception as err:
|
|
raise StorageQueryError(f"【OpenList】解析查询结果失败: {path} - {err}") from err
|
|
if result.get("code") != 200:
|
|
message = str(result.get("message") or "")
|
|
if "not found" in message.lower() or "not exist" in message.lower():
|
|
return None
|
|
raise StorageQueryError(f"【OpenList】查询文件 {path} 失败:{message}")
|
|
return self.__build_fileitem(path, result["data"])
|
|
|
|
def get_parent(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
|
"""
|
|
获取父目录
|
|
|
|
:param fileitem: 文件项
|
|
:return: 父目录项
|
|
"""
|
|
return self.get_folder(Path(fileitem.path).parent)
|
|
|
|
def delete(self, fileitem: _SchemaFileItem) -> bool:
|
|
"""
|
|
删除文件或目录
|
|
|
|
:param fileitem: 文件项
|
|
:return: 是否删除成功
|
|
"""
|
|
path = Path(fileitem.path)
|
|
name = fileitem.name or path.name
|
|
if not name:
|
|
logger.warn(f"【OpenList】删除路径 {fileitem.path} 无效")
|
|
return False
|
|
|
|
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
|
|
self.__get_api_url("/api/fs/remove"),
|
|
json={
|
|
"dir": path.parent.as_posix(),
|
|
"names": [name],
|
|
},
|
|
)
|
|
if resp is None:
|
|
logger.warn(
|
|
f"【OpenList】请求删除文件 {fileitem.path} 失败,无法连接alist服务"
|
|
)
|
|
return False
|
|
if resp.status_code != 200:
|
|
logger.warn(
|
|
f"【OpenList】请求删除文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
|
)
|
|
return False
|
|
result = resp.json()
|
|
if result["code"] != 200:
|
|
logger.warn(
|
|
f"【OpenList】删除文件 {fileitem.path} 失败,错误信息:{result['message']}"
|
|
)
|
|
return False
|
|
return True
|
|
|
|
def rename(self, fileitem: _SchemaFileItem, name: str) -> bool:
|
|
"""
|
|
重命名文件
|
|
|
|
:param fileitem: 文件项
|
|
:param name: 新文件名
|
|
:return: 是否重命名成功
|
|
"""
|
|
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
|
|
self.__get_api_url("/api/fs/rename"),
|
|
json={
|
|
"name": name,
|
|
"path": fileitem.path,
|
|
},
|
|
)
|
|
"""
|
|
{
|
|
"name": "test3",
|
|
"path": "/阿里云盘/test2"
|
|
}
|
|
======================================
|
|
{
|
|
"code": 200,
|
|
"message": "success",
|
|
"data": null
|
|
}
|
|
"""
|
|
if not resp:
|
|
logger.warn(
|
|
f"【OpenList】请求重命名文件 {fileitem.path} 失败,无法连接alist服务"
|
|
)
|
|
return False
|
|
if resp.status_code != 200:
|
|
logger.warn(
|
|
f"【OpenList】请求重命名文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
|
)
|
|
return False
|
|
|
|
result = resp.json()
|
|
if result["code"] != 200:
|
|
logger.warn(
|
|
f"【OpenList】重命名文件 {fileitem.path} 失败,错误信息:{result['message']}"
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
def download(
|
|
self,
|
|
fileitem: _SchemaFileItem,
|
|
path: Path = None,
|
|
password: Optional[str] = "",
|
|
) -> Optional[Path]:
|
|
"""
|
|
下载文件,保存到本地,返回本地临时文件地址
|
|
:param fileitem: 文件项
|
|
:param path: 文件保存路径
|
|
:param password: 文件密码
|
|
:return: 本地临时文件地址
|
|
"""
|
|
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
|
|
self.__get_api_url("/api/fs/get"),
|
|
json={
|
|
"path": fileitem.path,
|
|
"password": password,
|
|
"page": 1,
|
|
"per_page": 0,
|
|
"refresh": False,
|
|
},
|
|
)
|
|
"""
|
|
{
|
|
"code": 200,
|
|
"message": "success",
|
|
"data": {
|
|
"name": "[ANi]輝夜姬想讓人告白~天才們的戀愛頭腦戰~[01][1080P][Baha][WEB-DL].mp4",
|
|
"size": 924933111,
|
|
"is_dir": false,
|
|
"modified": "1970-01-01T00:00:00Z",
|
|
"created": "1970-01-01T00:00:00Z",
|
|
"sign": "1v0xkMQz_uG8fkEOQ7-l58OnbB-g4GkdBlUBcrsApCQ=:0",
|
|
"thumb": "",
|
|
"type": 2,
|
|
"hashinfo": "null",
|
|
"hash_info": null,
|
|
"raw_url": "xxxxxx",
|
|
"readme": "",
|
|
"header": "",
|
|
"provider": "UrlTree",
|
|
"related": null
|
|
}
|
|
}
|
|
"""
|
|
if not resp:
|
|
logger.warn(f"【OpenList】请求获取文件 {path} 失败,无法连接alist服务")
|
|
return None
|
|
if resp.status_code != 200:
|
|
logger.warn(
|
|
f"【OpenList】请求获取文件 {path} 失败,状态码:{resp.status_code}"
|
|
)
|
|
return None
|
|
|
|
result = resp.json()
|
|
if result["code"] != 200:
|
|
logger.warn(
|
|
f"【OpenList】获取文件 {path} 失败,错误信息:{result['message']}"
|
|
)
|
|
return None
|
|
|
|
if result["data"]["raw_url"]:
|
|
download_url = result["data"]["raw_url"]
|
|
else:
|
|
download_url = UrlUtils.adapt_request_url(
|
|
self.__get_base_url, f"/d{fileitem.path}"
|
|
)
|
|
if result["data"]["sign"]:
|
|
download_url = download_url + "?sign=" + result["data"]["sign"]
|
|
|
|
if not path:
|
|
local_path = settings.TEMP_PATH / fileitem.name
|
|
else:
|
|
local_path = path / fileitem.name
|
|
|
|
request_utils = RequestUtils(headers=self.__get_header_with_token())
|
|
try:
|
|
with request_utils.get_stream(download_url, raise_exception=True) as r:
|
|
r.raise_for_status()
|
|
with open(local_path, "wb") as f:
|
|
for chunk in r.iter_content(chunk_size=8192):
|
|
if global_vars.is_transfer_stopped(fileitem.path):
|
|
logger.info(f"【OpenList】{fileitem.path} 下载已取消!")
|
|
return None
|
|
f.write(chunk)
|
|
except Exception as e:
|
|
logger.error(f"【OpenList】下载文件 {fileitem.path} 失败:{e}")
|
|
if local_path.exists():
|
|
return local_path
|
|
|
|
return local_path
|
|
|
|
def upload(
|
|
self,
|
|
fileitem: _SchemaFileItem,
|
|
path: Path,
|
|
new_name: Optional[str] = None,
|
|
task: bool = False,
|
|
) -> Optional[_SchemaFileItem]:
|
|
"""
|
|
上传文件(带进度)
|
|
:param fileitem: 上传目录项
|
|
:param path: 本地文件路径
|
|
:param new_name: 上传后文件名
|
|
:param task: 是否为任务,默认为False避免未完成上传时对文件进行操作
|
|
:return: 上传后的文件项
|
|
"""
|
|
try:
|
|
# 获取文件大小
|
|
target_name = new_name or path.name
|
|
target_path = Path(fileitem.path) / target_name
|
|
stat = path.stat()
|
|
|
|
# 初始化进度回调
|
|
progress_callback = transfer_process(path.as_posix())
|
|
|
|
# 准备上传请求
|
|
encoded_path = UrlUtils.quote(target_path.as_posix())
|
|
headers = self.__get_header_with_token()
|
|
headers.setdefault("Content-Type", "application/octet-stream")
|
|
headers.setdefault("As-Task", str(task).lower())
|
|
headers.setdefault("File-Path", encoded_path)
|
|
headers.setdefault("Content-Length", str(stat.st_size))
|
|
headers.setdefault("Last-Modified", str(int(stat.st_mtime * 1000)))
|
|
headers.update(self.__get_upload_hash_headers(path))
|
|
|
|
# 创建自定义的文件流,支持进度回调
|
|
class ProgressFileReader:
|
|
def __init__(self, file_path: Path, callback):
|
|
self.file = open(file_path, "rb")
|
|
self.callback = callback
|
|
self.uploaded_size = 0
|
|
self.file_size = file_path.stat().st_size
|
|
|
|
def __len__(self) -> int:
|
|
return self.file_size
|
|
|
|
def read(self, size=-1):
|
|
if global_vars.is_transfer_stopped(path.as_posix()):
|
|
logger.info(f"【OpenList】{path} 上传已取消!")
|
|
raise OperationInterrupted(f"Upload cancelled: {path}")
|
|
chunk = self.file.read(size)
|
|
if chunk:
|
|
self.uploaded_size += len(chunk)
|
|
if self.callback:
|
|
percent = (self.uploaded_size * 100) / self.file_size
|
|
self.callback(percent)
|
|
return chunk
|
|
|
|
def close(self):
|
|
self.file.close()
|
|
|
|
# 使用自定义文件流上传
|
|
progress_reader = ProgressFileReader(path, progress_callback)
|
|
try:
|
|
resp = RequestUtils(headers=headers, timeout=6000).put_res(
|
|
self.__get_api_url("/api/fs/put"),
|
|
data=progress_reader,
|
|
)
|
|
except OperationInterrupted:
|
|
return None
|
|
finally:
|
|
progress_reader.close()
|
|
|
|
if resp is None:
|
|
logger.warn(f"【OpenList】请求上传文件 {path} 失败")
|
|
return None
|
|
if resp.status_code != 200:
|
|
logger.warn(
|
|
f"【OpenList】请求上传文件 {path} 失败,状态码:{resp.status_code}"
|
|
)
|
|
return None
|
|
|
|
# 完成上传
|
|
progress_callback(100)
|
|
|
|
# 获取上传后的文件项
|
|
new_item = self._delay_get_item(target_path, refresh=True)
|
|
if new_item and new_name and new_name != path.name:
|
|
if self.rename(new_item, new_name):
|
|
return self._delay_get_item(
|
|
Path(new_item.path).with_name(new_name), refresh=True
|
|
)
|
|
|
|
return new_item
|
|
|
|
except Exception as e:
|
|
logger.error(f"【OpenList】上传文件 {path} 失败:{e}")
|
|
return None
|
|
|
|
@staticmethod
|
|
def __get_upload_hash_headers(path: Path) -> dict:
|
|
"""
|
|
计算 OpenList 秒传所需的文件哈希请求头。
|
|
"""
|
|
md5_hash = hashlib.md5()
|
|
sha1_hash = hashlib.sha1()
|
|
sha256_hash = hashlib.sha256()
|
|
with open(path, "rb") as file_handler:
|
|
while True:
|
|
chunk = file_handler.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
md5_hash.update(chunk)
|
|
sha1_hash.update(chunk)
|
|
sha256_hash.update(chunk)
|
|
return {
|
|
"X-File-Md5": md5_hash.hexdigest(),
|
|
"X-File-Sha1": sha1_hash.hexdigest(),
|
|
"X-File-Sha256": sha256_hash.hexdigest(),
|
|
}
|
|
|
|
def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
|
"""
|
|
获取文件详情
|
|
"""
|
|
return self.get_item(Path(fileitem.path))
|
|
|
|
def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
|
"""
|
|
复制文件
|
|
:param fileitem: 文件项
|
|
:param path: 目标目录
|
|
:param new_name: 新文件名
|
|
:return: 是否复制成功
|
|
"""
|
|
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
|
|
self.__get_api_url("/api/fs/copy"),
|
|
json={
|
|
"src_dir": Path(fileitem.path).parent.as_posix(),
|
|
"dst_dir": path.as_posix(),
|
|
"names": [fileitem.name],
|
|
},
|
|
)
|
|
"""
|
|
{
|
|
"src_dir": "string",
|
|
"dst_dir": "string",
|
|
"names": [
|
|
"string"
|
|
]
|
|
}
|
|
======================================
|
|
{
|
|
"code": 200,
|
|
"message": "success",
|
|
"data": null
|
|
}
|
|
"""
|
|
if resp is None:
|
|
logger.warn(
|
|
f"【OpenList】请求复制文件 {fileitem.path} 失败,无法连接alist服务"
|
|
)
|
|
return False
|
|
if resp.status_code != 200:
|
|
logger.warn(
|
|
f"【OpenList】请求复制文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
|
)
|
|
return False
|
|
|
|
result = resp.json()
|
|
if result["code"] != 200:
|
|
logger.warn(
|
|
f"【OpenList】复制文件 {fileitem.path} 失败,错误信息:{result['message']}"
|
|
)
|
|
return False
|
|
# 重命名
|
|
if fileitem.name != new_name:
|
|
new_item = self._delay_get_item(path / fileitem.name, refresh=True)
|
|
if new_item:
|
|
self.rename(new_item, new_name)
|
|
return True
|
|
|
|
def copy_item(
|
|
self, fileitem: _SchemaFileItem, path: Path, new_name: str
|
|
) -> Optional[_SchemaFileItem]:
|
|
"""
|
|
复制文件并返回目标文件项,兼容 OpenList 成功响应不携带目标对象的格式。
|
|
"""
|
|
if not self.copy(fileitem=fileitem, path=path, new_name=new_name):
|
|
return None
|
|
target_path = path / new_name
|
|
target_item = self._delay_get_item(target_path, refresh=True)
|
|
if target_item:
|
|
return target_item
|
|
if fileitem.name == new_name:
|
|
return self.__build_transfer_item(fileitem, target_path)
|
|
|
|
copied_item = self._delay_get_item(path / fileitem.name, refresh=True)
|
|
if copied_item and self.rename(copied_item, new_name):
|
|
return self._delay_get_item(
|
|
target_path, refresh=True
|
|
) or self.__build_transfer_item(fileitem, target_path)
|
|
return None
|
|
|
|
def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
|
"""
|
|
移动文件
|
|
:param fileitem: 文件项
|
|
:param path: 目标目录
|
|
:param new_name: 新文件名
|
|
:return: 是否移动成功
|
|
"""
|
|
# 先重命名
|
|
if fileitem.name != new_name:
|
|
self.rename(fileitem, new_name)
|
|
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
|
|
self.__get_api_url("/api/fs/move"),
|
|
json={
|
|
"src_dir": Path(fileitem.path).parent.as_posix(),
|
|
"dst_dir": path.as_posix(),
|
|
"names": [new_name],
|
|
},
|
|
)
|
|
"""
|
|
{
|
|
"src_dir": "string",
|
|
"dst_dir": "string",
|
|
"names": [
|
|
"string"
|
|
]
|
|
}
|
|
======================================
|
|
{
|
|
"code": 200,
|
|
"message": "success",
|
|
"data": null
|
|
}
|
|
"""
|
|
if resp is None:
|
|
logger.warn(
|
|
f"【OpenList】请求移动文件 {fileitem.path} 失败,无法连接alist服务"
|
|
)
|
|
return False
|
|
if resp.status_code != 200:
|
|
logger.warn(
|
|
f"【OpenList】请求移动文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
|
)
|
|
return False
|
|
|
|
result = resp.json()
|
|
if result["code"] != 200:
|
|
logger.warn(
|
|
f"【OpenList】移动文件 {fileitem.path} 失败,错误信息:{result['message']}"
|
|
)
|
|
return False
|
|
return True
|
|
|
|
def move_item(
|
|
self, fileitem: _SchemaFileItem, path: Path, new_name: str
|
|
) -> Optional[_SchemaFileItem]:
|
|
"""
|
|
移动文件并返回目标文件项,兼容 OpenList 成功响应不携带目标对象的格式。
|
|
"""
|
|
if not self.move(fileitem=fileitem, path=path, new_name=new_name):
|
|
return None
|
|
target_path = path / new_name
|
|
return self._delay_get_item(target_path, refresh=True) or self.__build_transfer_item(
|
|
fileitem, target_path
|
|
)
|
|
|
|
def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
|
"""
|
|
硬链接文件
|
|
"""
|
|
pass
|
|
|
|
def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
|
"""
|
|
软链接文件
|
|
"""
|
|
pass
|
|
|
|
def usage(self) -> Optional[_SchemaStorageUsage]:
|
|
"""
|
|
存储使用情况
|
|
"""
|
|
pass
|
|
|
|
@staticmethod
|
|
def __parse_timestamp(time_str: str) -> float:
|
|
"""
|
|
直接使用 ISO 8601 格式解析时间
|
|
"""
|
|
return datetime.fromisoformat(time_str).timestamp()
|