Initial commit

This commit is contained in:
shiyu
2025-08-24 18:49:00 +08:00
parent 99866befe1
commit 6b0f2bd4fa
129 changed files with 11587 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from typing import List, Dict, Protocol, runtime_checkable, Tuple, AsyncIterator
from models import StorageAdapter
# 约定:任意新适配器模块需定义:
# ADAPTER_TYPE: str
# CONFIG_SCHEMA: List[Dict]
# ADAPTER_FACTORY: Callable[[StorageAdapter], BaseAdapter] (可省略, 会自动寻找 *Adapter 类)
@runtime_checkable
class BaseAdapter(Protocol):
record: StorageAdapter
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]: ...
async def read_file(self, root: str, rel: str) -> bytes: ...
async def write_file(self, root: str, rel: str, data: bytes): ...
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]): ...
async def mkdir(self, root: str, rel: str): ...
async def delete(self, root: str, rel: str): ...
async def move(self, root: str, src_rel: str, dst_rel: str): ...
async def rename(self, root: str, src_rel: str, dst_rel: str): ...
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False): ...
async def stream_file(self, root: str, rel: str, range_header: str | None): ...
async def stat_file(self, root: str, rel: str): ...
def get_effective_root(self, sub_path: str | None) -> str: ...
+342
View File
@@ -0,0 +1,342 @@
from __future__ import annotations
import os
import shutil
import stat
import time
from pathlib import Path
from typing import List, Dict, Tuple, AsyncIterator
import asyncio
import mimetypes
from fastapi import HTTPException
from fastapi.responses import StreamingResponse, Response
from models import StorageAdapter
from services.logging import LogService
def _safe_join(root: str, rel: str) -> Path:
root_path = Path(root).resolve()
full = (root_path / rel).resolve()
if not str(full).startswith(str(root_path)):
raise ValueError("Path escape detected")
return full
DEFAULT_FILE_MODE = 0o666
DEFAULT_DIR_MODE = 0o777
def _apply_mode(path: Path, mode: int):
try:
os.chmod(path, mode)
except Exception:
pass
class LocalAdapter:
def __init__(self, record: StorageAdapter):
self.record = record
self.root = self.record.config.get("root")
if not self.root:
raise ValueError("Local adapter config requires 'root'")
Path(self.root).mkdir(parents=True, exist_ok=True)
def get_effective_root(self, sub_path: str | None) -> str:
root = self.record.config.get("root")
if sub_path:
return str(Path(root) / sub_path)
return root
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
rel = rel.strip('/')
base = _safe_join(root, rel) if rel else Path(root)
if not base.exists():
return [], 0
if not base.is_dir():
raise NotADirectoryError(rel)
# 获取所有文件名并排序
all_names = await asyncio.to_thread(lambda: sorted(os.listdir(base), key=str.lower))
total_count = len(all_names)
# 计算分页范围
start_idx = (page_num - 1) * page_size
end_idx = start_idx + page_size
page_names = all_names[start_idx:end_idx]
entries = []
for name in page_names:
fp = base / name
try:
st = await asyncio.to_thread(fp.stat)
except FileNotFoundError:
continue
is_dir = fp.is_dir()
entries.append({
"name": name,
"is_dir": is_dir,
"size": 0 if is_dir else st.st_size,
"mtime": int(st.st_mtime),
"mode": stat.S_IMODE(st.st_mode),
"type": "dir" if is_dir else "file",
})
# 按目录优先排序
entries.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
return entries, total_count
async def read_file(self, root: str, rel: str) -> bytes:
fp = _safe_join(root, rel)
if not fp.exists() or not fp.is_file():
raise FileNotFoundError(rel)
return await asyncio.to_thread(fp.read_bytes)
async def write_file(self, root: str, rel: str, data: bytes):
fp = _safe_join(root, rel)
pre_exists = fp.exists()
await asyncio.to_thread(os.makedirs, fp.parent, mode=DEFAULT_DIR_MODE, exist_ok=True)
await asyncio.to_thread(fp.write_bytes, data)
if not pre_exists:
await asyncio.to_thread(_apply_mode, fp, DEFAULT_FILE_MODE)
await LogService.info(
"adapter:local",
f"Wrote file to {rel}",
details={"adapter_id": self.record.id, "path": str(fp), "size": len(data)},
)
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
fp = _safe_join(root, rel)
pre_exists = fp.exists()
await asyncio.to_thread(os.makedirs, fp.parent, mode=DEFAULT_DIR_MODE, exist_ok=True)
# 流式写入,避免一次性读入内存
def _open():
return open(fp, "wb")
f = await asyncio.to_thread(_open)
size = 0
try:
async for chunk in data_iter:
if not chunk:
continue
size += len(chunk)
await asyncio.to_thread(f.write, chunk)
finally:
await asyncio.to_thread(f.close)
if not pre_exists:
await asyncio.to_thread(_apply_mode, fp, DEFAULT_FILE_MODE)
await LogService.info(
"adapter:local",
f"Wrote file stream to {rel}",
details={"adapter_id": self.record.id, "path": str(fp), "size": size},
)
return size
async def mkdir(self, root: str, rel: str):
fp = _safe_join(root, rel)
await asyncio.to_thread(os.makedirs, fp, mode=DEFAULT_DIR_MODE, exist_ok=True)
await LogService.info(
"adapter:local",
f"Created directory {rel}",
details={"adapter_id": self.record.id, "path": str(fp)},
)
async def delete(self, root: str, rel: str):
fp = _safe_join(root, rel)
if not fp.exists():
return
if fp.is_dir():
await asyncio.to_thread(shutil.rmtree, fp)
else:
await asyncio.to_thread(fp.unlink)
await LogService.info(
"adapter:local",
f"Deleted {rel}",
details={"adapter_id": self.record.id, "path": str(fp)},
)
async def stat_path(self, root: str, rel: str):
"""新增: 返回路径状态调试信息"""
fp = _safe_join(root, rel)
def _stat():
if not fp.exists():
return {"exists": False, "is_dir": None, "path": str(fp)}
return {
"exists": True,
"is_dir": fp.is_dir(),
"path": str(fp)
}
return await asyncio.to_thread(_stat)
async def exists(self, root: str, rel: str) -> bool:
"""新增: 判断路径是否存在"""
fp = _safe_join(root, rel)
return await asyncio.to_thread(fp.exists)
async def move(self, root: str, src_rel: str, dst_rel: str):
src = _safe_join(root, src_rel)
dst = _safe_join(root, dst_rel)
if str(src) == str(dst):
return
if not src.exists():
raise FileNotFoundError(src_rel)
await asyncio.to_thread(dst.parent.mkdir, parents=True, exist_ok=True)
def _do_move():
try:
os.replace(src, dst)
except OSError:
shutil.move(str(src), str(dst))
await asyncio.to_thread(_do_move)
await LogService.info(
"adapter:local",
f"Moved {src_rel} to {dst_rel}",
details={
"adapter_id": self.record.id,
"src": str(src),
"dst": str(dst),
},
)
async def rename(self, root: str, src_rel: str, dst_rel: str):
src = _safe_join(root, src_rel)
dst = _safe_join(root, dst_rel)
if str(src) == str(dst):
return
if not src.exists():
raise FileNotFoundError(src_rel)
await asyncio.to_thread(dst.parent.mkdir, parents=True, exist_ok=True)
def _do_rename():
try:
os.rename(src, dst)
except OSError:
os.replace(src, dst)
await asyncio.to_thread(_do_rename)
await LogService.info(
"adapter:local",
f"Renamed {src_rel} to {dst_rel}",
details={
"adapter_id": self.record.id,
"src": str(src),
"dst": str(dst),
},
)
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
src = _safe_join(root, src_rel)
dst = _safe_join(root, dst_rel)
if not src.exists():
raise FileNotFoundError(src_rel)
if str(src) == str(dst):
return
await asyncio.to_thread(dst.parent.mkdir, parents=True, exist_ok=True)
def _do():
if dst.exists():
if not overwrite:
raise FileExistsError(dst_rel)
if dst.is_dir():
shutil.rmtree(dst)
else:
dst.unlink()
if src.is_dir():
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
await asyncio.to_thread(_do)
await LogService.info(
"adapter:local",
f"Copied {src_rel} to {dst_rel}",
details={
"adapter_id": self.record.id,
"src": str(src),
"dst": str(dst),
},
)
async def stream_file(self, root: str, rel: str, range_header: str | None):
fp = _safe_join(root, rel)
if not fp.exists() or not fp.is_file():
raise HTTPException(404, detail="File not found")
mime, _ = mimetypes.guess_type(rel)
content_type = mime or "application/octet-stream"
file_size = (await asyncio.to_thread(fp.stat)).st_size
start = 0
end = file_size - 1
status = 200
headers = {
"Accept-Ranges": "bytes",
"Content-Type": content_type,
}
if range_header and range_header.startswith("bytes="):
try:
part = range_header.removeprefix("bytes=")
s, e = part.split("-", 1)
if s.strip():
start = int(s)
if e.strip():
end = int(e)
if start >= file_size:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
if end >= file_size:
end = file_size - 1
status = 206
except ValueError:
raise HTTPException(400, detail="Invalid Range header")
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
headers["Content-Length"] = str(end - start + 1)
else:
headers["Content-Length"] = str(file_size)
async def iterator():
# 使用线程池避免阻塞
def _read_segment(offset: int, length: int):
with open(fp, "rb") as f:
f.seek(offset)
return f.read(length)
chunk_size = 256 * 1024
remaining = end - start + 1
offset = start
while remaining > 0:
size = min(chunk_size, remaining)
data = await asyncio.to_thread(_read_segment, offset, size)
if not data:
break
yield data
remaining -= len(data)
offset += len(data)
return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type)
async def stat_file(self, root: str, rel: str):
fp = _safe_join(root, rel)
if not fp.exists():
raise FileNotFoundError(rel)
st = await asyncio.to_thread(fp.stat)
info = {
"name": fp.name,
"is_dir": fp.is_dir(),
"size": st.st_size,
"mtime": int(st.st_mtime),
"mode": stat.S_IMODE(st.st_mode),
"type": "dir" if fp.is_dir() else "file",
"path": str(fp),
}
# exif信息
exif = None
if not fp.is_dir():
mime, _ = mimetypes.guess_type(fp.name)
if mime and mime.startswith("image/"):
try:
from PIL import Image
img = await asyncio.to_thread(Image.open, fp)
exif_data = img._getexif()
if exif_data:
exif = {str(k): str(v) for k, v in exif_data.items()}
except Exception:
exif = None
info["exif"] = exif
return info
ADAPTER_TYPE = "local"
CONFIG_SCHEMA = [
{"key": "root", "label": "根目录", "type": "string", "required": True, "placeholder": "/data/storage"},
]
ADAPTER_FACTORY = lambda rec: LocalAdapter(rec)
+83
View File
@@ -0,0 +1,83 @@
from typing import Dict, Callable
import pkgutil
import inspect
from importlib import import_module
from .base import BaseAdapter
from models import StorageAdapter
AdapterFactory = Callable[[StorageAdapter], object]
TYPE_MAP: Dict[str, AdapterFactory] = {}
CONFIG_SCHEMAS: Dict[str, list] = {}
def discover_adapters():
"""扫描 services.adapters 包, 自动注册适配器类型、工厂与配置 schema。"""
from .. import adapters as adapters_pkg
TYPE_MAP.clear()
CONFIG_SCHEMAS.clear()
for modinfo in pkgutil.iter_modules(adapters_pkg.__path__):
if modinfo.name.startswith("_"):
continue
full_name = f"{adapters_pkg.__name__}.{modinfo.name}"
try:
module = import_module(full_name)
except Exception:
continue
adapter_type = getattr(module, "ADAPTER_TYPE", None)
schema = getattr(module, "CONFIG_SCHEMA", None)
factory = getattr(module, "ADAPTER_FACTORY", None)
if not adapter_type:
continue
if factory is None:
for attr in module.__dict__.values():
if inspect.isclass(attr) and attr.__name__.endswith("Adapter"):
def _mk(cls=attr):
return lambda rec: cls(rec)
factory = _mk()
break
if not callable(factory):
continue
TYPE_MAP[adapter_type] = factory
if isinstance(schema, list):
CONFIG_SCHEMAS[adapter_type] = schema
def get_config_schemas() -> Dict[str, list]:
return CONFIG_SCHEMAS
def get_config_schema(adapter_type: str):
return CONFIG_SCHEMAS.get(adapter_type)
class RuntimeRegistry:
def __init__(self):
self._instances: Dict[int, object] = {}
async def refresh(self):
discover_adapters()
self._instances.clear()
adapters = await StorageAdapter.filter(enabled=True)
for rec in adapters:
factory = TYPE_MAP.get(rec.type)
if not factory:
continue
try:
self._instances[rec.id] = factory(rec)
except Exception:
continue
def get(self, adapter_id: int):
return self._instances.get(adapter_id)
def snapshot(self) -> Dict[int, BaseAdapter]:
return dict(self._instances)
runtime_registry = RuntimeRegistry()
discover_adapters()
+509
View File
@@ -0,0 +1,509 @@
from __future__ import annotations
from typing import List, Dict, Optional, Tuple, AsyncIterator
import httpx
from urllib.parse import urljoin, quote
from urllib.parse import urlparse, unquote
import xml.etree.ElementTree as ET
from models import StorageAdapter
import mimetypes
import logging
from fastapi import HTTPException
from fastapi.responses import StreamingResponse, Response
from services.logging import LogService
NS = {"d": "DAV:"}
class WebDAVAdapter:
def __init__(self, record: StorageAdapter):
self.record = record
cfg = record.config
self.base_url: str = cfg.get("base_url", "").rstrip('/') + '/'
if not self.base_url.startswith("http"):
raise ValueError("webdav requires base_url http/https")
self.username = cfg.get("username")
self.password = cfg.get("password")
self.timeout = cfg.get("timeout", 15)
def get_effective_root(self, sub_path: str | None) -> str:
base_url = self.record.config.get("base_url", "").rstrip('/') + '/'
if sub_path:
return base_url + sub_path.strip('/') + '/'
return base_url
def _client(self):
auth = (self.username, self.password) if self.username else None
return httpx.AsyncClient(auth=auth, timeout=self.timeout, follow_redirects=True)
def _build_url(self, rel: str):
rel = rel.strip('/')
return self.base_url if not rel else urljoin(self.base_url, quote(rel) + ('/' if rel.endswith('/') else ''))
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
raw_url = self._build_url(rel)
url = raw_url if raw_url.endswith('/') else raw_url + '/'
depth = "1"
body = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname />
<d:getcontentlength />
<d:getlastmodified />
<d:resourcetype />
</d:prop>
</d:propfind>"""
async with self._client() as client:
resp = await client.request("PROPFIND", url, data=body, headers={"Depth": depth})
resp.raise_for_status()
xml_text = resp.text
root_el = ET.fromstring(xml_text)
all_entries: List[Dict] = []
parsed_req = urlparse(url)
base_path = parsed_req.path
if not base_path.endswith('/'):
base_path += '/'
seen = set()
for resp_el in root_el.findall("d:response", NS):
href_el = resp_el.find("d:href", NS)
if href_el is None:
continue
href = (href_el.text or "")
parsed_href = urlparse(href)
href_path = parsed_href.path or ""
if not href_path.startswith(base_path):
continue
rel_path = href_path[len(base_path):].strip('/')
if rel_path == "":
continue
name = unquote(rel_path.split('/')[0]).rstrip('/')
if not name or name in seen:
continue
seen.add(name)
propstat = resp_el.find("d:propstat", NS)
if propstat is None:
continue
prop = propstat.find("d:prop", NS)
if prop is None:
continue
size_el = prop.find("d:getcontentlength", NS)
lm_el = prop.find("d:getlastmodified", NS)
rt_el = prop.find("d:resourcetype", NS)
is_dir = rt_el.find(
"d:collection", NS) is not None if rt_el is not None else href_path.endswith('/')
size = int(
size_el.text) if size_el is not None and size_el.text and size_el.text.isdigit() else 0
all_entries.append({
"name": name,
"is_dir": is_dir,
"size": 0 if is_dir else size,
"mtime": 0,
"type": "dir" if is_dir else "file",
})
# 排序所有条目
all_entries.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
total_count = len(all_entries)
# 应用分页
start_idx = (page_num - 1) * page_size
end_idx = start_idx + page_size
page_entries = all_entries[start_idx:end_idx]
return page_entries, total_count
async def read_file(self, root: str, rel: str) -> bytes:
url = self._build_url(rel)
async with self._client() as client:
resp = await client.get(url)
if resp.status_code == 404:
raise FileNotFoundError(rel)
resp.raise_for_status()
return resp.content
async def write_file(self, root: str, rel: str, data: bytes):
url = self._build_url(rel)
async with self._client() as client:
resp = await client.put(url, content=data)
resp.raise_for_status()
await LogService.info(
"adapter:webdav",
f"Wrote file to {rel}",
details={
"adapter_id": self.record.id,
"url": url,
"size": len(data),
},
)
async def mkdir(self, root: str, rel: str):
url = self._build_url(rel.rstrip('/') + '/')
async with self._client() as client:
resp = await client.request("MKCOL", url)
if resp.status_code not in (201, 405):
resp.raise_for_status()
await LogService.info(
"adapter:webdav",
f"Created directory {rel}",
details={"adapter_id": self.record.id, "url": url},
)
async def delete(self, root: str, rel: str):
url = self._build_url(rel)
async with self._client() as client:
resp = await client.delete(url)
if resp.status_code not in (204, 200, 404):
resp.raise_for_status()
await LogService.info(
"adapter:webdav",
f"Deleted {rel}",
details={"adapter_id": self.record.id, "url": url},
)
async def move(self, root: str, src_rel: str, dst_rel: str):
src_url = self._build_url(src_rel)
dst_url = self._build_url(dst_rel)
async with self._client() as client:
resp = await client.request("MOVE", src_url, headers={"Destination": dst_url})
resp.raise_for_status()
await LogService.info(
"adapter:webdav",
f"Moved {src_rel} to {dst_rel}",
details={
"adapter_id": self.record.id,
"src_url": src_url,
"dst_url": dst_url,
},
)
async def rename(self, root: str, src_rel: str, dst_rel: str):
src_url = self._build_url(src_rel)
dst_url = self._build_url(dst_rel)
async with self._client() as client:
resp = await client.request("MOVE", src_url, headers={"Destination": dst_url})
resp.raise_for_status()
await LogService.info(
"adapter:webdav",
f"Renamed {src_rel} to {dst_rel}",
details={
"adapter_id": self.record.id,
"src_url": src_url,
"dst_url": dst_url,
},
)
async def get_file_size(self, root: str, rel: str) -> int:
"""获取文件大小"""
url = self._build_url(rel)
async with self._client() as client:
# 使用HEAD请求获取文件信息
resp = await client.head(url)
if resp.status_code == 404:
raise FileNotFoundError(rel)
resp.raise_for_status()
content_length = resp.headers.get('content-length')
if content_length:
return int(content_length)
# 如果HEAD不返回content-length,尝试PROPFIND
body = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getcontentlength />
</d:prop>
</d:propfind>"""
resp = await client.request("PROPFIND", url, data=body, headers={"Depth": "0"})
resp.raise_for_status()
root_el = ET.fromstring(resp.text)
for resp_el in root_el.findall("d:response", NS):
propstat = resp_el.find("d:propstat", NS)
if propstat is None:
continue
prop = propstat.find("d:prop", NS)
if prop is None:
continue
size_el = prop.find("d:getcontentlength", NS)
if size_el is not None and size_el.text and size_el.text.isdigit():
return int(size_el.text)
return 0
async def read_file_range(self, root: str, rel: str, start: int, end: Optional[int] = None) -> bytes:
"""读取文件的指定范围"""
url = self._build_url(rel)
# 构建Range头
if end is None:
range_header = f"bytes={start}-"
else:
range_header = f"bytes={start}-{end}"
async with self._client() as client:
resp = await client.get(url, headers={"Range": range_header})
if resp.status_code == 404:
raise FileNotFoundError(rel)
if resp.status_code not in (200, 206): # 206是Partial Content
resp.raise_for_status()
return resp.content
async def stream_file(self, root: str, rel: str, range_header: str | None):
url = self._build_url(rel)
mime, _ = mimetypes.guess_type(rel)
content_type = mime or "application/octet-stream"
logger = logging.getLogger(__name__)
timeout = self.timeout
auth = (self.username, self.password) if self.username else None
client_start = 0
client_end = None
status_code = 200
if range_header and range_header.startswith("bytes="):
status_code = 206
part = range_header.removeprefix("bytes=")
s, e = part.split("-", 1)
if s.strip():
client_start = int(s)
if e.strip():
client_end = int(e)
total_size = None
accept_ranges = False
async with httpx.AsyncClient(timeout=timeout, auth=auth, follow_redirects=True) as client:
try:
head_resp = await client.head(url)
if head_resp.status_code == 404:
raise HTTPException(404, detail="File not found")
if head_resp.status_code == 200:
cl = head_resp.headers.get("Content-Length")
if cl and cl.isdigit():
total_size = int(cl)
ar = head_resp.headers.get("Accept-Ranges", "").lower()
accept_ranges = "bytes" in ar
except HTTPException:
raise
except Exception as e:
logger.debug("HEAD failed %s err=%s", url, e)
if total_size is None and (client_end is None):
try:
probe_req = client.build_request("GET", url, headers={"Range": "bytes=0-0"})
probe_resp = await client.send(probe_req, stream=True)
if probe_resp.status_code in (200, 206):
cr = probe_resp.headers.get("Content-Range")
if cr and "/" in cr:
try:
total_size = int(cr.rsplit("/", 1)[1])
except Exception:
pass
await probe_resp.aclose()
except Exception as e:
logger.debug("Probe 0-0 failed %s err=%s", url, e)
if total_size is not None and client_end is None:
client_end = total_size - 1
if client_end is not None and client_end < client_start:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
# 若客户端未请求范围且上游不支持 Range,直接透传
if status_code == 200 and (range_header is None) and not accept_ranges:
async with httpx.AsyncClient(timeout=timeout, auth=auth, follow_redirects=True) as client:
req = client.build_request("GET", url)
resp = await client.send(req, stream=True)
if resp.status_code == 404:
await resp.aclose()
raise HTTPException(404, detail="File not found")
upstream_ct = resp.headers.get("Content-Type", content_type)
async def passthrough():
try:
async for chunk in resp.aiter_bytes():
if chunk:
yield chunk
finally:
await resp.aclose()
return StreamingResponse(passthrough(), status_code=resp.status_code,
headers={"Accept-Ranges": "bytes",
"X-VFS-Remote-Status": str(resp.status_code)},
media_type=upstream_ct)
SEGMENT_SIZE = 5 * 1024 * 1024
MAX_RETRY_PER_SEG = 3
FIRST_BYTE_MAX_RETRY = 3
resp_headers = {
"Accept-Ranges": "bytes",
"Content-Type": content_type,
"X-VFS-Segmented": "1",
}
if status_code == 206 and total_size is not None:
resp_headers["Content-Range"] = f"bytes {client_start}-{client_end}/{total_size}"
async def segmented_body():
current = client_start
first_byte_sent = False
while True:
if client_end is not None and current > client_end:
break
seg_start = current
seg_end = (min(seg_start + SEGMENT_SIZE - 1, client_end)
if client_end is not None else seg_start + SEGMENT_SIZE - 1)
attempt = 0
ok = False
while attempt < MAX_RETRY_PER_SEG and not ok:
attempt += 1
headers_req = {"Range": f"bytes={seg_start}-{seg_end}"}
try:
async with httpx.AsyncClient(timeout=timeout, auth=auth, follow_redirects=True) as cseg:
req = cseg.build_request("GET", url, headers=headers_req)
rseg = await cseg.send(req, stream=True)
if rseg.status_code in (200, 206):
async for chunk in rseg.aiter_bytes():
if chunk:
first_byte_sent = True
yield chunk
await rseg.aclose()
ok = True
elif rseg.status_code == 404:
await rseg.aclose()
if not first_byte_sent:
raise HTTPException(404, detail="File not found")
return
else:
await rseg.aclose()
logger.warning("Segment unexpected status %s %s-%s %s", rel, seg_start, seg_end, rseg.status_code)
if not ok:
continue
except (httpx.ReadError, httpx.HTTPError, httpx.StreamError) as e:
if not first_byte_sent and attempt >= FIRST_BYTE_MAX_RETRY:
raise HTTPException(502, detail=f"Upstream error before first byte err={e}")
logger.warning("Segment error %s %s-%s attempt=%d err=%s", rel, seg_start, seg_end, attempt, e)
except Exception as e:
if not first_byte_sent:
raise
logger.error("Segment unexpected %s %s-%s attempt=%d err=%s", rel, seg_start, seg_end, attempt, e)
if not ok:
logger.error("Abort streaming %s at %s-%s", rel, seg_start, seg_end)
break
current = seg_end + 1
if client_end is None:
continue
if current > client_end:
break
return StreamingResponse(segmented_body(), status_code=status_code, headers=resp_headers, media_type=content_type)
async def stat_file(self, root: str, rel: str):
url = self._build_url(rel)
async with self._client() as client:
# PROPFIND 获取属性
body = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getcontentlength />
<d:getlastmodified />
<d:resourcetype />
</d:prop>
</d:propfind>"""
resp = await client.request("PROPFIND", url, data=body, headers={"Depth": "0"})
if resp.status_code == 404:
raise FileNotFoundError(rel)
resp.raise_for_status()
root_el = ET.fromstring(resp.text)
info = {
"name": rel.split("/")[-1],
"is_dir": False,
"size": None,
"mtime": None,
"type": "file",
"path": url,
}
for resp_el in root_el.findall("d:response", NS):
propstat = resp_el.find("d:propstat", NS)
if propstat is None:
continue
prop = propstat.find("d:prop", NS)
if prop is None:
continue
size_el = prop.find("d:getcontentlength", NS)
lm_el = prop.find("d:getlastmodified", NS)
rt_el = prop.find("d:resourcetype", NS)
is_dir = rt_el.find("d:collection", NS) is not None if rt_el is not None else False
info["is_dir"] = is_dir
info["type"] = "dir" if is_dir else "file"
if size_el is not None and size_el.text and size_el.text.isdigit():
info["size"] = int(size_el.text)
if lm_el is not None and lm_el.text:
info["mtime"] = lm_el.text
# exif信息
exif = None
if not info["is_dir"]:
mime, _ = mimetypes.guess_type(info["name"])
if mime and mime.startswith("image/"):
try:
resp_img = await client.get(url)
if resp_img.status_code == 200:
from PIL import Image
from io import BytesIO
img = Image.open(BytesIO(resp_img.content))
exif_data = img._getexif()
if exif_data:
exif = {str(k): str(v) for k, v in exif_data.items()}
except Exception:
exif = None
info["exif"] = exif
return info
async def exists(self, root: str, rel: str) -> bool:
url = self._build_url(rel)
async with self._client() as client:
try:
r = await client.head(url)
return r.status_code in (200, 204)
except Exception:
return False
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
url = self._build_url(rel)
async def agen():
async for chunk in data_iter:
if chunk:
yield chunk
async with self._client() as client:
resp = await client.put(url, content=agen())
resp.raise_for_status()
return True
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
src_url = self._build_url(src_rel)
dst_url = self._build_url(dst_rel)
headers = {
"Destination": dst_url,
"Overwrite": "T" if overwrite else "F"
}
async with self._client() as client:
resp = await client.request("COPY", src_url, headers=headers)
if resp.status_code == 412:
raise FileExistsError(dst_rel)
if resp.status_code == 404:
raise FileNotFoundError(src_rel)
resp.raise_for_status()
await LogService.info(
"adapter:webdav",
f"Copied {src_rel} to {dst_rel}",
details={
"adapter_id": self.record.id,
"src_url": src_url,
"dst_url": dst_url,
},
)
ADAPTER_TYPE = "webdav"
CONFIG_SCHEMA = [
{"key": "base_url", "label": "基础地址", "type": "string",
"required": True, "placeholder": "https://example.com/dav/"},
{"key": "username", "label": "用户名", "type": "string", "required": False},
{"key": "password", "label": "密码", "type": "password", "required": False},
{"key": "timeout",
"label": "超时(秒)", "type": "number", "required": False, "default": 15},
]
def ADAPTER_FACTORY(rec): return WebDAVAdapter(rec)