Files
LitePan/drivers/WebDAV/driver.py
T
2026-05-26 09:10:51 +08:00

1237 lines
48 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""WebDAV 驱动:PROPFIND 列目录 + MKCOL/DELETE/MOVE/PUT 写操作 + 直链/代理下载 + 流式上传。"""
from __future__ import annotations
import asyncio
import base64
import os
import random
import ssl
import tempfile
import unicodedata
from datetime import datetime
from email.utils import parsedate_to_datetime
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
from urllib.parse import quote, unquote, urljoin, urlparse
import xml.etree.ElementTree as ET
import aiohttp
from aiohttp import BasicAuth
from aiohttp.payload import AsyncIterablePayload
from fastapi import UploadFile
from core.base import DriverInfo, FileItem, OperationResult
from core.driver_base import BaseDriver
from core.log_manager import LogModule, get_writer
from core.operation_wrapper import (
auto_cleanup_cache,
with_file_info_cache,
with_file_list_cache,
)
from .config import WebdavConfig
DAV = "DAV:"
_URL_PATH_SAFE = "-._~!$&'()*+,;=:@"
# 幂等方法:遇到 429/5xx/网络异常可以重试
_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "PROPFIND", "OPTIONS"})
def _dav(tag: str) -> str:
return f"{{{DAV}}}{tag}"
def _local_tag(tag: str) -> str:
if tag.startswith("{"):
return tag.split("}", 1)[-1]
return tag
def _nfc(value: str) -> str:
"""对名字做 Unicode NFC 归一化,规避 macOS / 某些上游返回 NFD 的差异。"""
if not value:
return value or ""
try:
return unicodedata.normalize("NFC", value)
except Exception:
return value
def _safe_unquote(value: str) -> str:
"""反解 href:兼容 `+` 表示空格、`%2520` 这种二次编码。"""
if not value:
return value or ""
raw = value
raw = raw.replace("+", " ")
decoded = unquote(raw)
if "%" in decoded and decoded != value:
try:
again = unquote(decoded)
if again != decoded:
decoded = again
except Exception:
pass
return _nfc(decoded)
class _SizedAsyncIterablePayload(AsyncIterablePayload):
def __init__(self, value, *, size: int, **kwargs) -> None:
super().__init__(value, **kwargs)
self._size = int(size)
class WebdavDriver(BaseDriver):
_UPLOAD_CHUNK_SIZE: int = 4 * 1024 * 1024
_PROGRESS_MIN_INTERVAL: float = 0.2
def __init__(self, config: WebdavConfig):
super().__init__(config)
self.config: WebdavConfig = config
self._log = get_writer(LogModule.DRIVER)
self._session: Optional[aiohttp.ClientSession] = None
self._root_collection_url: str = ""
self._account_root_path: str = ""
# 并发闸门按账号一份;init() 创建 session 时一并初始化,便于在 close() 中清掉
self._concurrency_sem: Optional[asyncio.Semaphore] = None
# redirect 模式:当某个 file_id 跟随到匿名公网直链时,缓存它的有效期;
# get_download_headers 据此判断要不要清 Authorization
self._anon_redirect_until: Dict[str, float] = {}
@classmethod
def get_info(cls) -> DriverInfo:
return DriverInfo(
name="webdav",
display_name="WebDAV",
version="0.1.0",
capabilities=[
"list",
"info",
"download",
"create_folder",
"delete",
"batch_delete",
"rename",
"move",
"copy",
"upload",
],
description="将远端 WebDAV 挂载为存储(浏览、下载、上传、目录与改名/移动/删除)",
author="LitePan",
)
async def init(self) -> None:
self._root_collection_url = self._build_root_collection_url(self.config)
parsed = urlparse(self._root_collection_url)
path = _safe_unquote(parsed.path or "/")
self._account_root_path = path.rstrip("/") or "/"
# 同账号下用一个并发闸门,避免播放器探测/STRM 扫描时同时把上游打爆
max_conc = int(getattr(self.config, "max_concurrency", 4) or 4)
if max_conc < 1:
max_conc = 1
self._concurrency_sem = asyncio.Semaphore(max_conc)
self._log.debug(
f"WebDAV 初始化: root={self._root_collection_url} max_conc={max_conc}",
driver_name="webdav",
)
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
self._session = None
self._concurrency_sem = None
self._log.debug("WebDAV 驱动已关闭", driver_name="webdav")
async def test_connection(self) -> OperationResult:
try:
await self._propfind_raw(self._root_collection_url, depth="0")
return OperationResult(success=True, message="连接成功")
except Exception as e:
msg = str(e)
self._log.error(f"WebDAV 连接测试失败: {msg}", driver_name="webdav")
return OperationResult(success=False, message=f"连接失败: {msg}")
@staticmethod
def _build_root_collection_url(cfg: WebdavConfig) -> str:
raw = (cfg.base_url or "").strip().rstrip("/")
root_piece = (cfg.root_path or "").strip().strip("/")
if root_piece:
joined = urljoin(raw + "/", root_piece + "/")
else:
joined = raw + "/"
if not joined.endswith("/"):
joined += "/"
return joined
def _normalize_parent_rel(self, parent_id: Optional[str]) -> str:
if parent_id is None:
return ""
p = str(parent_id).strip()
if p in ("0", "/", ""):
return ""
return p.strip("/")
@staticmethod
def _encode_rel_segments(rel: str) -> str:
parts = [p for p in (rel or "").split("/") if p]
return "/".join(quote(p, safe=_URL_PATH_SAFE) for p in parts)
def _collection_url_for_parent(self, parent_rel: str) -> str:
if not parent_rel:
return self._root_collection_url
base = self._root_collection_url.rstrip("/")
enc = self._encode_rel_segments(parent_rel)
return f"{base}/{enc}/"
def _resource_url_for_rel(self, rel: str, *, is_dir: bool) -> str:
rel = (rel or "").strip().strip("/")
base = self._root_collection_url.rstrip("/")
if not rel:
url = self._root_collection_url
else:
enc = self._encode_rel_segments(rel)
url = f"{base}/{enc}"
if is_dir and not url.endswith("/"):
url += "/"
return url
def _href_to_path(self, href: str) -> str:
href = (href or "").strip()
if href.startswith("http://") or href.startswith("https://"):
return _safe_unquote(urlparse(href).path or "/")
if href.startswith("/"):
return _safe_unquote(href)
return _safe_unquote(urlparse(urljoin(self._root_collection_url, href)).path or "/")
def _rel_path_from_account_root(self, path: str) -> str:
p = _nfc((path or "").rstrip("/"))
root = _nfc(self._account_root_path.rstrip("/"))
if root == "/" or root == "":
rel = p.lstrip("/")
else:
if not p.startswith(root):
p2 = "/" + p.lstrip("/")
if not p2.startswith(root):
return ""
p = p2
rel = p[len(root) :].lstrip("/")
return rel.strip("/")
def _is_direct_child(self, parent_rel: str, child_rel: str) -> bool:
parent_rel = _nfc((parent_rel or "").strip().strip("/"))
child_rel = _nfc((child_rel or "").strip().strip("/"))
if not child_rel:
return False
if not parent_rel:
return "/" not in child_rel
if child_rel == parent_rel:
return False
if not (child_rel.startswith(parent_rel + "/")):
return False
suffix = child_rel[len(parent_rel) + 1 :]
return suffix != "" and "/" not in suffix
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
if self.config.verify_ssl:
ssl_param: Any = None
else:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ssl_param = ctx
# 加上连接池上限,避免长时间持有连接导致后续 PROPFIND 起不来
connector = aiohttp.TCPConnector(
ssl=ssl_param,
limit=32,
limit_per_host=16,
keepalive_timeout=30,
enable_cleanup_closed=True,
)
# 这里给 session 一个保底的总超时(按上传超时来),具体每次请求会再传 timeout 覆盖
base_total = int(self.config.timeout_seconds or 60)
timeout = aiohttp.ClientTimeout(total=base_total, sock_connect=15)
self._session = aiohttp.ClientSession(connector=connector, timeout=timeout)
return self._session
def _propfind_timeout(self) -> aiohttp.ClientTimeout:
seconds = int(getattr(self.config, "timeout_seconds", 60) or 60)
return aiohttp.ClientTimeout(total=seconds, sock_connect=10, sock_read=seconds)
def _read_timeout(self) -> aiohttp.ClientTimeout:
return aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=60)
def _upload_timeout(self) -> aiohttp.ClientTimeout:
return aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=120)
async def _acquire_concurrency(self) -> None:
sem = self._concurrency_sem
if sem is not None:
await sem.acquire()
def _release_concurrency(self) -> None:
sem = self._concurrency_sem
if sem is not None:
try:
sem.release()
except ValueError:
pass
@staticmethod
def _retry_after_seconds(resp: aiohttp.ClientResponse) -> float:
raw = resp.headers.get("Retry-After") if resp is not None else None
if not raw:
return 0.0
raw = raw.strip()
if raw.isdigit():
try:
return max(0.0, float(int(raw)))
except ValueError:
return 0.0
try:
target = parsedate_to_datetime(raw)
now = datetime.utcnow().replace(tzinfo=target.tzinfo) if target.tzinfo else datetime.utcnow()
delta = (target - now).total_seconds()
return max(0.0, float(delta))
except (TypeError, ValueError, OverflowError):
return 0.0
def _basic_auth(self) -> Optional[BasicAuth]:
u = (self.config.username or "").strip()
p = (self.config.password or "").strip()
if not u:
return None
return BasicAuth(u, p)
def _authorization_header_dict(self) -> Dict[str, str]:
u = (self.config.username or "").strip()
if not u:
return {}
p = (self.config.password or "").strip()
token = base64.b64encode(f"{u}:{p}".encode("utf-8")).decode("ascii")
return {"Authorization": f"Basic {token}"}
@staticmethod
def _parent_rel_of(rel: str) -> str:
rel = (rel or "").strip().strip("/")
if not rel or "/" not in rel:
return ""
return rel.rsplit("/", 1)[0]
@staticmethod
def _to_api_parent_id(parent_rel: str) -> str:
return "0" if not (parent_rel or "").strip().strip("/") else parent_rel.strip().strip("/")
async def _maybe_operation_delay(self) -> None:
delay_ms = int(getattr(self.config, "operation_delay", 0) or 0)
if delay_ms > 0:
await asyncio.sleep(delay_ms / 1000.0)
async def _request_once(
self,
method: str,
url: str,
*,
headers: Optional[Dict[str, str]],
data: Optional[Any],
timeout: aiohttp.ClientTimeout,
allow_redirects: bool,
read_body: bool = True,
) -> Tuple[int, str, float]:
session = await self._get_session()
auth = self._basic_auth()
async with session.request(
method,
url,
headers=headers or None,
data=data,
auth=auth,
allow_redirects=allow_redirects,
timeout=timeout,
) as resp:
body_text = await resp.text() if read_body else ""
retry_after = self._retry_after_seconds(resp)
return resp.status, body_text, retry_after
async def _request_with_retry(
self,
method: str,
url: str,
*,
headers: Optional[Dict[str, str]] = None,
data: Optional[Any] = None,
timeout: Optional[aiohttp.ClientTimeout] = None,
allow_redirects: bool = True,
ok_status: Tuple[int, ...] = (200, 201, 204, 207),
read_body: bool = True,
) -> Tuple[int, str]:
method_upper = method.upper()
max_retries = int(getattr(self.config, "max_retries", 2) or 0)
retriable = method_upper in _IDEMPOTENT_METHODS
last_exc: Optional[BaseException] = None
last_status: Optional[int] = None
last_body: str = ""
attempt = 0
while True:
attempt += 1
await self.wait_for_request_interval()
await self._acquire_concurrency()
try:
status, body, retry_after = await self._request_once(
method_upper,
url,
headers=headers,
data=data,
timeout=timeout or aiohttp.ClientTimeout(total=int(self.config.timeout_seconds or 60)),
allow_redirects=allow_redirects,
read_body=read_body,
)
last_status, last_body = status, body
if status in ok_status:
return status, body
# 鉴权失败立即抛
if status == 401:
raise PermissionError("WebDAV 返回 401,请检查用户名与密码")
if status == 403:
raise PermissionError(f"WebDAV 返回 403: {(body or '')[:400]}")
# 仅对幂等请求重试 429/5xx;总尝试次数 = 1 + max_retries
if retriable and (status == 429 or 500 <= status <= 599) and attempt <= max_retries:
sleep = retry_after if retry_after > 0 else min(2.0 ** (attempt - 1) * 0.5, 5.0)
sleep += random.uniform(0, 0.3)
self._log.debug(
f"WebDAV {method_upper} {url} 收到 {status},{sleep:.2f}s 后重试(第 {attempt}/{max_retries + 1} 次)",
driver_name="webdav",
)
await asyncio.sleep(sleep)
continue
snippet = (body or "")[:400]
raise RuntimeError(f"WebDAV {method_upper} 失败 HTTP {status}: {snippet}")
except (asyncio.TimeoutError, aiohttp.ClientError) as e:
last_exc = e
if retriable and attempt <= max_retries:
sleep = min(2.0 ** (attempt - 1) * 0.5, 5.0) + random.uniform(0, 0.3)
self._log.debug(
f"WebDAV {method_upper} {url} 网络异常 {type(e).__name__}: {e},{sleep:.2f}s 后重试",
driver_name="webdav",
)
await asyncio.sleep(sleep)
continue
raise
finally:
self._release_concurrency()
# 理论上到不了这里
if last_exc:
raise last_exc
raise RuntimeError(f"WebDAV {method_upper} 失败 HTTP {last_status}: {(last_body or '')[:400]}")
async def _simple_request(
self,
method: str,
url: str,
*,
headers: Optional[Dict[str, str]] = None,
data: Optional[Any] = None,
ok_status: tuple = (200, 201, 204),
allow_redirects: bool = False,
timeout: Optional[aiohttp.ClientTimeout] = None,
) -> None:
await self._request_with_retry(
method,
url,
headers=headers,
data=data,
timeout=timeout or self._propfind_timeout(),
allow_redirects=allow_redirects,
ok_status=tuple(ok_status),
)
async def _resource_exists(self, rel: str, *, is_dir: bool = False) -> bool:
try:
await self._propfind_raw(self._resource_url_for_rel(rel, is_dir=is_dir), depth="0")
return True
except PermissionError:
# 401/403 不代表不存在,需要把错误透出去
raise
except Exception:
pass
try:
await self._propfind_raw(self._resource_url_for_rel(rel, is_dir=not is_dir), depth="0")
return True
except Exception:
return False
async def _follow_redirect_url(self, url: str) -> Optional[str]:
await self.wait_for_request_interval()
session = await self._get_session()
auth = self._basic_auth()
final_url: Optional[str] = None
try:
await self._acquire_concurrency()
try:
async with session.request(
"HEAD",
url,
auth=auth,
allow_redirects=True,
timeout=self._read_timeout(),
) as resp:
if 200 <= resp.status < 400 and str(resp.url) != url:
final_url = str(resp.url)
except Exception:
pass
if not final_url:
try:
async with session.request(
"GET",
url,
auth=auth,
allow_redirects=True,
timeout=self._read_timeout(),
headers={"Range": "bytes=0-0"},
) as resp:
if 200 <= resp.status < 400 and str(resp.url) != url:
final_url = str(resp.url)
except Exception:
pass
finally:
self._release_concurrency()
if not final_url:
return None
try:
await self._acquire_concurrency()
try:
async with session.request(
"GET",
final_url,
auth=None,
allow_redirects=True,
timeout=self._read_timeout(),
headers={"Range": "bytes=0-0"},
) as resp:
if 200 <= resp.status < 400:
return final_url
except Exception:
return None
finally:
self._release_concurrency()
return None
async def _propfind_raw(self, url: str, *, depth: str) -> str:
headers = {
"Depth": depth,
"Content-Type": "application/xml; charset=utf-8",
}
body = (
'<?xml version="1.0" encoding="utf-8"?>'
'<propfind xmlns="DAV:"><prop>'
"<resourcetype/>"
"<getcontentlength/>"
"<getlastmodified/>"
"<displayname/>"
"</prop></propfind>"
)
try:
_, text = await self._request_with_retry(
"PROPFIND",
url,
headers=headers,
data=body.encode("utf-8"),
timeout=self._propfind_timeout(),
allow_redirects=True,
ok_status=(200, 207),
)
return text
except PermissionError:
raise
except RuntimeError as e:
msg = str(e)
if "HTTP " in msg and "失败" in msg:
raise
raise RuntimeError(f"WebDAV PROPFIND 失败: {msg}") from e
def _parse_propfind_response(
self,
xml_text: str,
*,
parent_rel: str,
collection_url: str,
) -> List[FileItem]:
try:
root = ET.fromstring(xml_text)
except ET.ParseError as e:
raise RuntimeError(f"无法解析 WebDAV 响应 XML: {e}") from e
collection_path = self._href_to_path(collection_url).rstrip("/")
items: List[FileItem] = []
for response in root.iter():
if _local_tag(response.tag) != "response":
continue
href_el = None
prop_el = None
for child in list(response):
ln = _local_tag(child.tag)
if ln == "href":
href_el = child
elif ln == "propstat":
st = child.find(_dav("status"))
st_text = (st.text or "").upper() if st is not None else ""
if "200" in st_text or "OK" in st_text:
p = child.find(_dav("prop"))
if p is not None:
prop_el = p
break
if href_el is None or prop_el is None:
continue
href_raw = (href_el.text or "").strip()
path = self._href_to_path(href_raw).rstrip("/")
if collection_path and (path == collection_path or path.rstrip("/") == collection_path):
continue
rel = self._rel_path_from_account_root(path)
if rel == (parent_rel or "").strip().strip("/"):
continue
if not self._is_direct_child(parent_rel, rel):
continue
is_dir = False
rt = prop_el.find(_dav("resourcetype"))
if rt is not None and rt.find(_dav("collection")) is not None:
is_dir = True
display = prop_el.find(_dav("displayname"))
name = _nfc((display.text or "").strip()) if display is not None else ""
if not name:
name = rel.split("/")[-1] or rel or "unknown"
size = 0
cl = prop_el.find(_dav("getcontentlength"))
if cl is not None and (cl.text or "").strip().isdigit():
size = int(cl.text.strip())
modified: Optional[datetime] = None
lm = prop_el.find(_dav("getlastmodified"))
if lm is not None and (lm.text or "").strip():
try:
modified = parsedate_to_datetime(lm.text.strip())
except (TypeError, ValueError, OverflowError):
modified = None
parent_api = self._to_api_parent_id(parent_rel)
items.append(
FileItem(
id=rel,
name=name,
path=f"/{rel}" if rel else "/",
size=0 if is_dir else size,
is_dir=is_dir,
modified=modified,
extra={"webdav_href": href_raw, "parent_id": parent_api},
)
)
items.sort(key=lambda x: (not x.is_dir, x.name.lower()))
return items
def _parse_single_resource(self, xml_text: str, rel: str) -> Optional[FileItem]:
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return None
for response in root.iter():
if _local_tag(response.tag) != "response":
continue
prop_el = None
href_el = None
for child in list(response):
ln = _local_tag(child.tag)
if ln == "href":
href_el = child
elif ln == "propstat":
st = child.find(_dav("status"))
st_text = (st.text or "").upper() if st is not None else ""
if "200" in st_text or "OK" in st_text:
p = child.find(_dav("prop"))
if p is not None:
prop_el = p
break
if href_el is None or prop_el is None:
continue
href_raw = (href_el.text or "").strip()
path = self._href_to_path(href_raw)
item_rel = self._rel_path_from_account_root(path.rstrip("/"))
if _nfc(item_rel) != _nfc(rel.strip().strip("/")):
continue
is_dir = False
rt = prop_el.find(_dav("resourcetype"))
if rt is not None and rt.find(_dav("collection")) is not None:
is_dir = True
display = prop_el.find(_dav("displayname"))
name = _nfc((display.text or "").strip()) if display is not None else ""
if not name:
name = rel.split("/")[-1] or rel
size = 0
cl = prop_el.find(_dav("getcontentlength"))
if cl is not None and (cl.text or "").strip().isdigit():
size = int(cl.text.strip())
modified = None
lm = prop_el.find(_dav("getlastmodified"))
if lm is not None and (lm.text or "").strip():
try:
modified = parsedate_to_datetime(lm.text.strip())
except (TypeError, ValueError, OverflowError):
modified = None
parent_api = self._to_api_parent_id(self._parent_rel_of(item_rel))
return FileItem(
id=item_rel,
name=name,
path=f"/{item_rel}" if item_rel else "/",
size=0 if is_dir else size,
is_dir=is_dir,
modified=modified,
extra={"webdav_href": href_raw, "parent_id": parent_api},
)
return None
@with_file_list_cache
async def list_files(self, parent_id: str = "0") -> List[FileItem]:
parent_rel = self._normalize_parent_rel(parent_id)
url = self._collection_url_for_parent(parent_rel)
xml_text = await self._propfind_raw(url, depth="1")
return self._parse_propfind_response(xml_text, parent_rel=parent_rel, collection_url=url)
@with_file_info_cache
async def file_info(self, file_id: str) -> Optional[FileItem]:
rel = self._normalize_parent_rel(file_id)
if rel == "":
return None
try:
xml_file = await self._propfind_raw(
self._resource_url_for_rel(rel, is_dir=False), depth="0"
)
hit = self._parse_single_resource(xml_file, rel)
if hit:
return hit
except PermissionError:
raise
except Exception:
pass
try:
xml_try_dir = await self._propfind_raw(
self._resource_url_for_rel(rel, is_dir=True), depth="0"
)
return self._parse_single_resource(xml_try_dir, rel)
except PermissionError:
raise
except Exception as e:
self._log.debug(f"WebDAV file_info 失败: {rel} {e}", driver_name="webdav")
return None
@staticmethod
def _validate_entry_name(name: str) -> Optional[str]:
n = (name or "").strip()
if not n:
return None
if "/" in n or "\\" in n or n in (".", ".."):
return None
return n
def _is_virtual_root_id(self, file_id: Optional[str]) -> bool:
p = (file_id or "").strip()
return p in ("", "0", "/")
@auto_cleanup_cache("create_folder")
async def create_folder(self, parent_id: str, name: str) -> OperationResult:
try:
folder_name = self._validate_entry_name(name)
if not folder_name:
return OperationResult(success=False, message="文件夹名称无效")
parent_rel = self._normalize_parent_rel(parent_id)
child_rel = f"{parent_rel}/{folder_name}" if parent_rel else folder_name
url = self._resource_url_for_rel(child_rel, is_dir=True)
try:
await self._simple_request("MKCOL", url, ok_status=(200, 201, 204))
except RuntimeError as e:
msg = str(e)
if "HTTP 405" in msg or "HTTP 409" in msg:
return OperationResult(
success=False,
message=f"无法创建文件夹(可能已存在): {folder_name}",
)
raise
await self._maybe_operation_delay()
return OperationResult(
success=True,
message=f"文件夹 '{folder_name}' 创建成功",
data={
"folder_id": child_rel,
"parent_path": self._to_api_parent_id(parent_rel),
"folder_name": folder_name,
},
)
except Exception as e:
self._log.error(f"WebDAV 新建文件夹失败: {e}", driver_name="webdav")
return OperationResult(success=False, message=f"新建文件夹失败: {str(e)}")
@auto_cleanup_cache("delete_file")
async def delete_file(self, file_id: str) -> OperationResult:
return await self._delete_files([file_id])
@auto_cleanup_cache("batch_delete_file")
async def batch_delete_file(self, file_ids: List[str]) -> OperationResult:
if not file_ids:
return OperationResult(success=True, message="没有文件需要删除")
return await self._delete_files(file_ids)
async def _delete_files(self, file_ids: List[str]) -> OperationResult:
try:
parent_ids: set[str] = set()
prepared: List[tuple[str, bool]] = []
for fid in file_ids:
if self._is_virtual_root_id(fid):
return OperationResult(success=False, message="不能删除存储根目录")
info = await self.file_info(fid)
if not info:
return OperationResult(success=False, message=f"找不到文件: {fid}")
if info.extra and info.extra.get("parent_id") is not None:
parent_ids.add(str(info.extra["parent_id"]))
prepared.append((fid, info.is_dir))
for fid, is_dir in prepared:
url = self._resource_url_for_rel(fid, is_dir=is_dir)
await self._simple_request("DELETE", url, ok_status=(200, 204))
await self._maybe_operation_delay()
return OperationResult(
success=True,
message=f"已删除 {len(file_ids)} 项",
data={
"deleted_count": len(file_ids),
"file_ids": file_ids,
"parent_ids": list(parent_ids),
},
)
except Exception as e:
self._log.error(f"WebDAV 删除失败: {e}", driver_name="webdav")
return OperationResult(success=False, message=f"删除失败: {str(e)}")
@auto_cleanup_cache("rename_file")
async def rename_file(self, file_id: str, new_name: str) -> OperationResult:
try:
if self._is_virtual_root_id(file_id):
return OperationResult(success=False, message="不能重命名根目录")
new_base = self._validate_entry_name(new_name)
if not new_base:
return OperationResult(success=False, message="新名称无效")
info = await self.file_info(file_id)
if not info:
return OperationResult(success=False, message="找不到要重命名的文件")
old_name = info.name
parent_rel = self._parent_rel_of(file_id)
dest_rel = f"{parent_rel}/{new_base}" if parent_rel else new_base
if dest_rel.strip("/") == file_id.strip("/"):
return OperationResult(
success=True,
message="名称未变化",
data={
"file_id": file_id,
"new_name": new_base,
"old_name": old_name,
"parent_id": self._to_api_parent_id(parent_rel),
},
)
src_url = self._resource_url_for_rel(file_id, is_dir=info.is_dir)
dest_url = self._resource_url_for_rel(dest_rel, is_dir=info.is_dir)
await self._simple_request(
"MOVE",
src_url,
headers={"Destination": dest_url, "Overwrite": "T"},
ok_status=(200, 201, 204),
)
await self._maybe_operation_delay()
return OperationResult(
success=True,
message=f"已重命名为「{new_base}」",
data={
"file_id": dest_rel,
"new_name": new_base,
"old_name": old_name,
"parent_id": self._to_api_parent_id(parent_rel),
},
)
except Exception as e:
self._log.error(f"WebDAV 重命名失败: {e}", driver_name="webdav")
return OperationResult(success=False, message=f"重命名失败: {str(e)}")
@auto_cleanup_cache("move_file")
async def move_file(self, file_ids: List[str], target_parent_id: str) -> OperationResult:
try:
if not file_ids:
return OperationResult(success=True, message="没有文件需要移动")
target_rel = self._normalize_parent_rel(target_parent_id)
source_parents: set[str] = set()
for fid in file_ids:
if self._is_virtual_root_id(fid):
return OperationResult(success=False, message="不能移动根目录")
info = await self.file_info(fid)
if not info:
return OperationResult(success=False, message=f"找不到文件: {fid}")
if info.extra and info.extra.get("parent_id") is not None:
source_parents.add(str(info.extra.get("parent_id")))
base = fid.split("/")[-1] if "/" in fid else fid
dest_rel = f"{target_rel}/{base}" if target_rel else base
if dest_rel.strip("/") == fid.strip("/"):
continue
src_url = self._resource_url_for_rel(fid, is_dir=info.is_dir)
dest_url = self._resource_url_for_rel(dest_rel, is_dir=info.is_dir)
await self._simple_request(
"MOVE",
src_url,
headers={"Destination": dest_url, "Overwrite": "T"},
ok_status=(200, 201, 204),
)
await self._maybe_operation_delay()
return OperationResult(
success=True,
message=f"已移动 {len(file_ids)} 项",
data={
"moved_count": len(file_ids),
"file_ids": file_ids,
"target_parent_id": self._to_api_parent_id(target_rel),
"source_parent_ids": list(source_parents),
},
)
except Exception as e:
self._log.error(f"WebDAV 移动失败: {e}", driver_name="webdav")
return OperationResult(success=False, message=f"移动失败: {str(e)}")
@auto_cleanup_cache("copy_file")
async def copy_file(self, file_ids: List[str], target_parent_id: str, source_parent_id: str = None) -> OperationResult:
try:
if not file_ids:
return OperationResult(success=True, message="没有文件需要复制")
target_rel = self._normalize_parent_rel(target_parent_id)
for fid in file_ids:
if self._is_virtual_root_id(fid):
return OperationResult(success=False, message="不能复制根目录")
info = await self.file_info(fid)
if not info:
return OperationResult(success=False, message=f"找不到文件: {fid}")
base = fid.split("/")[-1] if "/" in fid else fid
dest_rel = f"{target_rel}/{base}" if target_rel else base
if dest_rel.strip("/") == fid.strip("/"):
return OperationResult(
success=False,
message="WebDAV 不支持复制到同一目录(路径冲突)",
data={"warning": True},
)
src_url = self._resource_url_for_rel(fid, is_dir=info.is_dir)
dest_url = self._resource_url_for_rel(dest_rel, is_dir=info.is_dir)
await self._simple_request(
"COPY",
src_url,
headers={"Destination": dest_url, "Overwrite": "T"},
ok_status=(200, 201, 204),
)
await self._maybe_operation_delay()
return OperationResult(
success=True,
message=f"已复制 {len(file_ids)} 项",
data={
"copied_count": len(file_ids),
"file_ids": file_ids,
"target_parent_id": self._to_api_parent_id(target_rel),
"source_parent_ids": [source_parent_id] if source_parent_id else [],
},
)
except Exception as e:
self._log.error(f"WebDAV 复制失败: {e}", driver_name="webdav")
return OperationResult(success=False, message=f"复制失败: {str(e)}")
async def batch_copy_file(self, file_ids: List[str], target_parent_id: str) -> OperationResult:
return await self.copy_file(file_ids, target_parent_id)
async def get_download_url(self, file_id: str, user_agent: str = "") -> str:
rel = self._normalize_parent_rel(file_id)
if not rel:
raise ValueError("无效的下载路径")
url = self._resource_url_for_rel(rel, is_dir=False)
if self.config.download_mode != "redirect":
self._anon_redirect_until.pop(file_id, None)
return url
# redirect 模式:尝试跟随 302 到无需鉴权的最终直链;拿不到时自动降级为 proxy
final = await self._follow_redirect_url(url)
if final:
# 缓存 60s 内,get_download_headers 不带 Authorization;之后过期重测
self._anon_redirect_until[file_id] = asyncio.get_event_loop().time() + 60.0
return final
if self._basic_auth() is not None:
self._log.warning(
"WebDAV redirect 模式未拿到匿名直链,本次按 proxy 模式回源;"
"若长期如此,建议把账号下载策略改为 proxy",
driver_name="webdav",
)
self._anon_redirect_until.pop(file_id, None)
return url
async def get_download_headers(self, file_id: str, user_agent: str = "") -> Dict[str, str]:
# redirect 模式拿到匿名直链时不能带 Authorization 头(对象存储会拒绝),统一返回空头
expires_at = self._anon_redirect_until.get(file_id)
if expires_at and expires_at > asyncio.get_event_loop().time():
return {}
if expires_at:
self._anon_redirect_until.pop(file_id, None)
return self._authorization_header_dict()
async def get_download_info(self, file_id: str, user_agent: str = "") -> Dict[str, Any]:
info = await self.file_info(file_id)
if not info or info.is_dir:
raise ValueError("只能下载文件")
upstream_url = self._resource_url_for_rel(self._normalize_parent_rel(file_id), is_dir=False)
url = await self.get_download_url(file_id, user_agent)
configured_mode = (self.config.download_mode or "proxy").strip() or "proxy"
auth_headers = self._authorization_header_dict()
if configured_mode == "redirect" and url and url != upstream_url:
headers = {}
effective_mode = "redirect"
elif configured_mode == "redirect" and auth_headers:
headers = auth_headers
effective_mode = "proxy"
else:
headers = auth_headers
effective_mode = configured_mode
return {
"download_url": url,
"file_name": info.name or file_id.split("/")[-1],
"size": int(info.size or 0),
"headers": headers,
"effective_mode": effective_mode,
}
async def _save_upload_to_tempfile(self, upload_file: UploadFile) -> str:
suffix = os.path.splitext(upload_file.filename or "")[1]
fd, temp_path = tempfile.mkstemp(prefix="litepan_webdav_", suffix=suffix)
os.close(fd)
try:
with open(temp_path, "wb") as temp_fp:
while True:
chunk = await upload_file.read(1024 * 1024)
if not chunk:
break
temp_fp.write(chunk)
return temp_path
except Exception:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
raise
async def upload_file(
self,
upload_file: UploadFile,
parent_path: str = "0",
conflict_policy: str = "overwrite",
) -> OperationResult:
temp_path = ""
try:
temp_path = await self._save_upload_to_tempfile(upload_file)
return await self.upload_local_file(
temp_path,
upload_file.filename or "",
parent_path,
conflict_policy=conflict_policy,
)
finally:
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
try:
await upload_file.close()
except Exception:
pass
async def upload_local_file_with_resume(
self,
local_path: str,
file_name: str,
parent_path: str = "0",
progress_callback: Optional[Callable[[int, int, str], Awaitable[None]]] = None,
conflict_policy: str = "overwrite",
resume_state: Optional[Dict[str, Any]] = None,
state_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
) -> OperationResult:
return await self.upload_local_file(
local_path,
file_name,
parent_path,
progress_callback=progress_callback,
conflict_policy=conflict_policy,
resume_state=resume_state,
state_callback=state_callback,
)
async def _iter_file_chunks(
self,
local_path: str,
*,
file_size: int,
progress_callback: Optional[Callable[[int, int, str], Awaitable[None]]] = None,
message: str = "正在上传",
):
chunk_size = max(1, int(self._UPLOAD_CHUNK_SIZE))
uploaded = 0
last_emit_at = 0.0
loop = asyncio.get_running_loop()
def _open_file():
return open(local_path, "rb")
def _read_chunk(fp) -> bytes:
return fp.read(chunk_size)
def _close_file(fp) -> None:
try:
fp.close()
except Exception:
pass
fp = await asyncio.to_thread(_open_file)
try:
while True:
chunk = await asyncio.to_thread(_read_chunk, fp)
if not chunk:
break
yield chunk
uploaded += len(chunk)
if progress_callback is None:
continue
now = loop.time()
reached_end = uploaded >= file_size
if reached_end or (now - last_emit_at) >= self._PROGRESS_MIN_INTERVAL:
last_emit_at = now
try:
await progress_callback(uploaded, file_size, message)
except Exception as emit_err:
self._log.debug(
f"WebDAV 进度回调异常(忽略): {emit_err}",
driver_name="webdav",
)
finally:
await asyncio.to_thread(_close_file, fp)
@auto_cleanup_cache("upload_file")
async def upload_local_file(
self,
local_path: str,
file_name: str,
parent_path: str = "0",
progress_callback: Optional[Callable[[int, int, str], Awaitable[None]]] = None,
conflict_policy: str = "overwrite",
resume_state: Optional[Dict[str, Any]] = None,
state_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
) -> OperationResult:
_ = (resume_state, state_callback)
async def _notify_progress(uploaded: int, total: int, message: str) -> None:
if progress_callback:
try:
await progress_callback(uploaded, total, message)
except Exception as emit_err:
self._log.debug(
f"WebDAV 进度回调异常(忽略): {emit_err}",
driver_name="webdav",
)
try:
target_name = self._validate_entry_name(os.path.basename((file_name or "").strip()))
if not target_name:
return OperationResult(success=False, message="上传文件名无效")
if not local_path or not os.path.exists(local_path):
return OperationResult(success=False, message="待上传文件不存在")
parent_rel = self._normalize_parent_rel(parent_path)
child_rel = f"{parent_rel}/{target_name}" if parent_rel else target_name
parent_api = self._to_api_parent_id(parent_rel)
if conflict_policy == "skip" and await self._resource_exists(child_rel, is_dir=False):
await _notify_progress(0, 0, "已跳过")
return OperationResult(
success=True,
message=f"文件「{target_name}」已存在,已跳过",
data={
"skipped": True,
"file_name": target_name,
"parent_id": parent_api,
},
)
if conflict_policy != "overwrite" and await self._resource_exists(child_rel, is_dir=False):
return OperationResult(
success=False,
message=f"目标已存在且策略为 {conflict_policy},无法上传",
)
url = self._resource_url_for_rel(child_rel, is_dir=False)
file_size = os.path.getsize(local_path)
await _notify_progress(0, file_size, "正在上传")
if file_size == 0:
await self._simple_request(
"PUT",
url,
headers={"Content-Length": "0"},
data=b"",
ok_status=(200, 201, 204),
timeout=self._upload_timeout(),
)
else:
chunk_iter = self._iter_file_chunks(
local_path,
file_size=file_size,
progress_callback=progress_callback,
)
payload = _SizedAsyncIterablePayload(
chunk_iter,
size=file_size,
content_type="application/octet-stream",
)
await self._simple_request(
"PUT",
url,
data=payload,
ok_status=(200, 201, 204),
timeout=self._upload_timeout(),
)
await self._maybe_operation_delay()
await _notify_progress(file_size, file_size, "上传成功")
return OperationResult(
success=True,
message=f"文件「{target_name}」上传成功",
data={
"file_name": target_name,
"parent_id": parent_api,
"file_id": child_rel,
"size": file_size,
},
)
except Exception as e:
self._log.error(f"WebDAV 上传失败: {e}", driver_name="webdav")
return OperationResult(success=False, message=f"上传失败: {str(e)}")