diff --git a/domain/adapters/api.py b/domain/adapters/api.py index 7a3db35..54d1e24 100644 --- a/domain/adapters/api.py +++ b/domain/adapters/api.py @@ -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) diff --git a/domain/adapters/providers/base.py b/domain/adapters/providers/base.py index 0bb3bf9..39bb47a 100644 --- a/domain/adapters/providers/base.py +++ b/domain/adapters/providers/base.py @@ -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: ... diff --git a/domain/adapters/providers/dropbox.py b/domain/adapters/providers/dropbox.py index 26ab886..8aa966c 100644 --- a/domain/adapters/providers/dropbox.py +++ b/domain/adapters/providers/dropbox.py @@ -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) - diff --git a/domain/adapters/providers/googledrive.py b/domain/adapters/providers/googledrive.py index 1dcf123..4e1b720 100644 --- a/domain/adapters/providers/googledrive.py +++ b/domain/adapters/providers/googledrive.py @@ -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" diff --git a/domain/adapters/providers/local.py b/domain/adapters/providers/local.py index 65de173..c2b1cce 100644 --- a/domain/adapters/providers/local.py +++ b/domain/adapters/providers/local.py @@ -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 = [ diff --git a/domain/adapters/providers/onedrive.py b/domain/adapters/providers/onedrive.py index 3d93f0a..bfc0aa0 100644 --- a/domain/adapters/providers/onedrive.py +++ b/domain/adapters/providers/onedrive.py @@ -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" diff --git a/domain/adapters/providers/pikpak.py b/domain/adapters/providers/pikpak.py index d15f34f..379de5e 100644 --- a/domain/adapters/providers/pikpak.py +++ b/domain/adapters/providers/pikpak.py @@ -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: diff --git a/domain/adapters/providers/quark.py b/domain/adapters/providers/quark.py index 1b2ad0c..8692316 100644 --- a/domain/adapters/providers/quark.py +++ b/domain/adapters/providers/quark.py @@ -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 / 辅助 # ----------------- diff --git a/domain/adapters/service.py b/domain/adapters/service.py index 663f83e..d97cc6f 100644 --- a/domain/adapters/service.py +++ b/domain/adapters/service.py @@ -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) diff --git a/domain/adapters/types.py b/domain/adapters/types.py index ffcc270..0bdf0f9 100644 --- a/domain/adapters/types.py +++ b/domain/adapters/types.py @@ -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 diff --git a/web/src/api/adapters.ts b/web/src/api/adapters.ts index c9e6a62..b26286f 100644 --- a/web/src/api/adapters.ts +++ b/web/src/api/adapters.ts @@ -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('/adapters'), create: (payload: Omit) => request('/adapters', { method: 'POST', json: payload }), update: (id: number, payload: Omit) => request(`/adapters/${id}`, { method: 'PUT', json: payload }), remove: (id: number) => request(`/adapters/${id}`, { method: 'DELETE' }), available: () => request('/adapters/available'), + usage: () => request('/adapters/usage'), + usageById: (id: number) => request(`/adapters/${id}/usage`), };