Files
MoviePilot/app/modules/filemanager/storages/alist.py
T

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()