mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-07 05:42:56 +08:00
feat: add adapter usage tracking and retrieval methods across various adapters
This commit is contained in:
@@ -51,6 +51,29 @@ async def available_adapter_types(
|
||||
return success(data)
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
@audit(action=AuditAction.READ, description="获取适配器容量使用情况")
|
||||
@require_system_permission(AdapterPermission.LIST)
|
||||
async def list_adapter_usages(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
usages = await AdapterService.list_adapter_usages()
|
||||
return success(usages)
|
||||
|
||||
|
||||
@router.get("/{adapter_id}/usage")
|
||||
@audit(action=AuditAction.READ, description="获取单个适配器容量使用情况")
|
||||
@require_system_permission(AdapterPermission.LIST)
|
||||
async def get_adapter_usage(
|
||||
request: Request,
|
||||
adapter_id: int,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
usage = await AdapterService.get_adapter_usage(adapter_id)
|
||||
return success(usage)
|
||||
|
||||
|
||||
@router.get("/{adapter_id}")
|
||||
@audit(action=AuditAction.READ, description="获取适配器详情")
|
||||
@require_system_permission(AdapterPermission.LIST)
|
||||
|
||||
@@ -21,3 +21,8 @@ class BaseAdapter(Protocol):
|
||||
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: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UsageCapableAdapter(Protocol):
|
||||
async def get_usage(self, root: str) -> Dict: ...
|
||||
|
||||
@@ -455,6 +455,23 @@ class DropboxAdapter:
|
||||
|
||||
return StreamingResponse(iterator(), status_code=resp.status_code, headers=out_headers, media_type=content_type)
|
||||
|
||||
async def get_usage(self, root: str):
|
||||
resp = await self._api_json("/users/get_space_usage", {})
|
||||
resp.raise_for_status()
|
||||
payload = resp.json() or {}
|
||||
allocation = payload.get("allocation") or {}
|
||||
allocated = allocation.get("allocated")
|
||||
used = payload.get("used")
|
||||
total = int(allocated) if allocated is not None else None
|
||||
used_bytes = int(used) if used is not None else None
|
||||
return {
|
||||
"used_bytes": used_bytes,
|
||||
"total_bytes": total,
|
||||
"free_bytes": total - used_bytes if total is not None and used_bytes is not None else None,
|
||||
"source": "dropbox",
|
||||
"scope": "account",
|
||||
}
|
||||
|
||||
|
||||
ADAPTER_TYPE = "dropbox"
|
||||
CONFIG_SCHEMA = [
|
||||
@@ -468,4 +485,3 @@ CONFIG_SCHEMA = [
|
||||
|
||||
|
||||
def ADAPTER_FACTORY(rec): return DropboxAdapter(rec)
|
||||
|
||||
|
||||
@@ -541,6 +541,22 @@ class GoogleDriveAdapter:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_usage(self, root: str):
|
||||
resp = await self._request("GET", "/about", params={"fields": "storageQuota"})
|
||||
resp.raise_for_status()
|
||||
quota = (resp.json() or {}).get("storageQuota") or {}
|
||||
limit = quota.get("limit")
|
||||
usage = quota.get("usage")
|
||||
total = int(limit) if limit is not None else None
|
||||
used = int(usage) if usage is not None else None
|
||||
return {
|
||||
"used_bytes": used,
|
||||
"total_bytes": total,
|
||||
"free_bytes": total - used if total is not None and used is not None else None,
|
||||
"source": "googledrive",
|
||||
"scope": "drive",
|
||||
}
|
||||
|
||||
|
||||
ADAPTER_TYPE = "googledrive"
|
||||
|
||||
|
||||
@@ -329,6 +329,29 @@ class LocalAdapter:
|
||||
info["exif"] = exif
|
||||
return info
|
||||
|
||||
async def get_usage(self, root: str):
|
||||
root_path = Path(root).resolve()
|
||||
|
||||
def _usage():
|
||||
used = 0
|
||||
for dirpath, dirnames, filenames in os.walk(root_path):
|
||||
for filename in filenames:
|
||||
fp = Path(dirpath) / filename
|
||||
try:
|
||||
used += fp.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
disk = shutil.disk_usage(root_path)
|
||||
return {
|
||||
"used_bytes": used,
|
||||
"total_bytes": disk.total,
|
||||
"free_bytes": disk.free,
|
||||
"source": "local",
|
||||
"scope": "mount",
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_usage)
|
||||
|
||||
|
||||
ADAPTER_TYPE = "local"
|
||||
CONFIG_SCHEMA = [
|
||||
|
||||
@@ -443,6 +443,21 @@ class OneDriveAdapter:
|
||||
resp.raise_for_status()
|
||||
return self._format_item(resp.json())
|
||||
|
||||
async def get_usage(self, root: str):
|
||||
resp = await self._request("GET", full_url=f"{MS_GRAPH_URL}/me/drive?$select=quota")
|
||||
resp.raise_for_status()
|
||||
quota = (resp.json() or {}).get("quota") or {}
|
||||
used = quota.get("used")
|
||||
total = quota.get("total")
|
||||
remaining = quota.get("remaining")
|
||||
return {
|
||||
"used_bytes": int(used) if used is not None else None,
|
||||
"total_bytes": int(total) if total is not None else None,
|
||||
"free_bytes": int(remaining) if remaining is not None else None,
|
||||
"source": "onedrive",
|
||||
"scope": "drive",
|
||||
}
|
||||
|
||||
|
||||
ADAPTER_TYPE = "onedrive"
|
||||
|
||||
|
||||
@@ -776,6 +776,21 @@ class PikPakAdapter:
|
||||
return None
|
||||
return resp.content
|
||||
|
||||
async def get_usage(self, root: str):
|
||||
data = await self._request("GET", "/about")
|
||||
quota = data.get("quota") or {}
|
||||
limit = quota.get("limit")
|
||||
usage = quota.get("usage")
|
||||
total = int(limit) if limit is not None else None
|
||||
used = int(usage) if usage is not None else None
|
||||
return {
|
||||
"used_bytes": used,
|
||||
"total_bytes": total,
|
||||
"free_bytes": total - used if total is not None and used is not None else None,
|
||||
"source": "pikpak",
|
||||
"scope": "drive",
|
||||
}
|
||||
|
||||
async def mkdir(self, root: str, rel: str):
|
||||
rel = (rel or "").strip("/")
|
||||
if not rel:
|
||||
|
||||
@@ -840,6 +840,23 @@ class QuarkAdapter:
|
||||
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
||||
raise NotImplementedError("QuarkOpen does not support copy via open API")
|
||||
|
||||
async def get_usage(self, root: str):
|
||||
data = await self._request("GET", "/capacity/growth/info")
|
||||
payload = (data or {}).get("data") or {}
|
||||
if isinstance(payload.get("member"), dict):
|
||||
payload = payload["member"]
|
||||
used = payload.get("use_capacity") or payload.get("used_capacity")
|
||||
total = payload.get("total_capacity")
|
||||
used_bytes = int(used) if used is not None else None
|
||||
total_bytes = int(total) if total is not None else None
|
||||
return {
|
||||
"used_bytes": used_bytes,
|
||||
"total_bytes": total_bytes,
|
||||
"free_bytes": total_bytes - used_bytes if total_bytes is not None and used_bytes is not None else None,
|
||||
"source": "quark",
|
||||
"scope": "account",
|
||||
}
|
||||
|
||||
# -----------------
|
||||
# STAT / EXISTS / 辅助
|
||||
# -----------------
|
||||
|
||||
@@ -8,7 +8,8 @@ from .registry import (
|
||||
normalize_adapter_type,
|
||||
runtime_registry,
|
||||
)
|
||||
from .types import AdapterCreate, AdapterOut
|
||||
from .types import AdapterCreate, AdapterOut, AdapterUsage
|
||||
from .providers.base import UsageCapableAdapter
|
||||
from models import StorageAdapter
|
||||
|
||||
|
||||
@@ -85,6 +86,68 @@ class AdapterService:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
return AdapterOut.model_validate(rec)
|
||||
|
||||
@classmethod
|
||||
def _unsupported_usage(cls, rec: StorageAdapter, reason: str) -> AdapterUsage:
|
||||
return AdapterUsage(
|
||||
id=rec.id,
|
||||
name=rec.name,
|
||||
type=rec.type,
|
||||
path=rec.path,
|
||||
supported=False,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_adapter_usage(cls, adapter_id: int) -> AdapterUsage:
|
||||
rec = await StorageAdapter.get_or_none(id=adapter_id)
|
||||
if not rec:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
return await cls._get_adapter_usage_for_record(rec)
|
||||
|
||||
@classmethod
|
||||
async def _get_adapter_usage_for_record(cls, rec: StorageAdapter) -> AdapterUsage:
|
||||
if not rec.enabled:
|
||||
return cls._unsupported_usage(rec, "adapter_disabled")
|
||||
|
||||
adapter = runtime_registry.get(rec.id)
|
||||
if not adapter:
|
||||
await runtime_registry.refresh()
|
||||
adapter = runtime_registry.get(rec.id)
|
||||
if not adapter:
|
||||
return cls._unsupported_usage(rec, "adapter_unavailable")
|
||||
if not isinstance(adapter, UsageCapableAdapter):
|
||||
return cls._unsupported_usage(rec, "adapter_not_implemented")
|
||||
|
||||
root = adapter.get_effective_root(rec.sub_path)
|
||||
try:
|
||||
raw_usage = await adapter.get_usage(root)
|
||||
except Exception as e:
|
||||
return cls._unsupported_usage(rec, f"usage_failed: {e}")
|
||||
|
||||
if not isinstance(raw_usage, dict):
|
||||
return cls._unsupported_usage(rec, "invalid_usage_response")
|
||||
|
||||
return AdapterUsage(
|
||||
id=rec.id,
|
||||
name=rec.name,
|
||||
type=rec.type,
|
||||
path=rec.path,
|
||||
supported=True,
|
||||
used_bytes=raw_usage.get("used_bytes"),
|
||||
total_bytes=raw_usage.get("total_bytes"),
|
||||
free_bytes=raw_usage.get("free_bytes"),
|
||||
source=raw_usage.get("source") or rec.type,
|
||||
scope=raw_usage.get("scope"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def list_adapter_usages(cls):
|
||||
adapters = await StorageAdapter.all()
|
||||
result = []
|
||||
for rec in adapters:
|
||||
result.append(await cls._get_adapter_usage_for_record(rec))
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def update_adapter(cls, adapter_id: int, data: AdapterCreate, current_user: Optional[User]):
|
||||
rec = await StorageAdapter.get_or_none(id=adapter_id)
|
||||
|
||||
@@ -48,3 +48,17 @@ class AdapterOut(AdapterBase):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdapterUsage(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
type: str
|
||||
path: str
|
||||
supported: bool
|
||||
used_bytes: Optional[int] = None
|
||||
total_bytes: Optional[int] = None
|
||||
free_bytes: Optional[int] = None
|
||||
source: Optional[str] = None
|
||||
scope: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
@@ -25,10 +25,26 @@ export interface AdapterTypeMeta {
|
||||
config_schema: AdapterTypeField[];
|
||||
}
|
||||
|
||||
export interface AdapterUsage {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
path: string;
|
||||
supported: boolean;
|
||||
used_bytes?: number | null;
|
||||
total_bytes?: number | null;
|
||||
free_bytes?: number | null;
|
||||
source?: string | null;
|
||||
scope?: string | null;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export const adaptersApi = {
|
||||
list: () => request<AdapterItem[]>('/adapters'),
|
||||
create: (payload: Omit<AdapterItem, 'id'>) => request<AdapterItem>('/adapters', { method: 'POST', json: payload }),
|
||||
update: (id: number, payload: Omit<AdapterItem, 'id'>) => request<AdapterItem>(`/adapters/${id}`, { method: 'PUT', json: payload }),
|
||||
remove: (id: number) => request<void>(`/adapters/${id}`, { method: 'DELETE' }),
|
||||
available: () => request<AdapterTypeMeta[]>('/adapters/available'),
|
||||
usage: () => request<AdapterUsage[]>('/adapters/usage'),
|
||||
usageById: (id: number) => request<AdapterUsage>(`/adapters/${id}/usage`),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user