Files
imagefind/backend/imagefind/usage.py
T

487 lines
20 KiB
Python

from __future__ import annotations
import json
import os
import shutil
import threading
import time
import xml.etree.ElementTree as ET
from datetime import UTC, datetime
from pathlib import Path
from .config import Settings
from .database import Database, utcnow
def _path_size(path: Path) -> int:
"""Return logical file sizes without following directory symlinks."""
try:
if path.is_symlink():
return 0
if path.is_file():
return path.stat().st_size
if not path.is_dir():
return 0
except OSError:
return 0
total = 0
stack = [path]
while stack:
current = stack.pop()
try:
with os.scandir(current) as entries:
for entry in entries:
try:
if entry.is_symlink():
continue
if entry.is_dir(follow_symlinks=False):
stack.append(Path(entry.path))
elif entry.is_file(follow_symlinks=False):
total += entry.stat(follow_symlinks=False).st_size
except OSError:
continue
except OSError:
continue
return total
def _root_size(path: Path, *, missing_available: bool = False) -> tuple[int, bool, str | None]:
try:
if path.is_symlink():
return 0, False, "符号链接目录不会被统计"
if not path.exists():
if missing_available:
return 0, True, None
return 0, False, "目录不存在"
if not os.access(path, os.R_OK):
return 0, False, "目录不可读"
return _path_size(path), True, None
except OSError as exc:
return 0, False, f"无法读取目录:{exc}"
def _entry(bytes_value: int, *, available: bool = True, reason: str | None = None, **extra) -> dict:
value = {"bytes": int(bytes_value), "available": bool(available)}
if reason:
value["reason"] = reason
value.update(extra)
return value
def _dedupe_roots(paths: list[Path]) -> list[Path]:
roots: list[Path] = []
for path in sorted({str(item) for item in paths}):
candidate = Path(path)
if any(candidate == root or root in candidate.parents for root in roots):
continue
roots = [root for root in roots if root not in candidate.parents]
roots.append(candidate)
return roots
class StorageUsageService:
def __init__(
self,
db: Database,
settings: Settings,
rclone=None,
remote_cache=None,
sources=None,
cache_seconds: float = 30.0,
):
self.db = db
self.settings = settings
self.rclone = rclone
self.remote_cache = remote_cache
self.sources = sources
self.cache_seconds = cache_seconds
self._lock = threading.Lock()
self._cached_at = 0.0
self._cached: dict | None = None
self._capacity_cache: dict[str, tuple[float, dict]] = {}
@staticmethod
def _capacity_result(
source_id: str,
*,
scope: str,
status: str,
provider: str,
total: int = 0,
used: int = 0,
available: int = 0,
reason: str | None = None,
cache_seconds: float = 30,
) -> dict:
result = {
"source_id": source_id,
"scope": scope,
"status": status,
"total_bytes": max(0, int(total)),
"used_bytes": max(0, int(used)),
"available_bytes": max(0, int(available)),
"provider": provider,
"collected_at": datetime.now(UTC).isoformat(),
"cached_seconds": int(cache_seconds),
}
if reason:
result["reason"] = reason
return result
def _webdav_capacity(self, source: dict) -> dict | None:
connector = self.sources.connector(source["id"])
try:
response = connector.client.request(
"PROPFIND",
connector.base_url,
headers={"Depth": "0", "Content-Type": "application/xml; charset=utf-8"},
content=(
"<?xml version='1.0' encoding='utf-8'?>"
"<d:propfind xmlns:d='DAV:'><d:prop><d:quota-used-bytes/>"
"<d:quota-available-bytes/></d:prop></d:propfind>"
),
)
if response.status_code != 207:
raise RuntimeError(f"WebDAV 配额查询失败:HTTP {response.status_code}")
root = ET.fromstring(response.content)
used_text = root.findtext(".//{DAV:}quota-used-bytes")
free_text = root.findtext(".//{DAV:}quota-available-bytes")
if used_text is None or free_text is None:
return None
used = max(0, int(used_text))
free = max(0, int(free_text))
return {"total_bytes": used + free, "used_bytes": used, "available_bytes": free}
finally:
connector.close()
def _calculate_capacity(self, source_id: str) -> dict:
if self.sources is None:
raise RuntimeError("媒体来源服务尚未配置")
source = self.sources.get(source_id)
if source["kind"] == "local":
try:
disk = shutil.disk_usage(Path(source["config"]["path"]))
except OSError as exc:
return self._capacity_result(
source_id,
scope="local",
status="unreachable",
provider="local",
reason=f"本地来源不可访问:{type(exc).__name__}",
cache_seconds=self.cache_seconds,
)
return self._capacity_result(
source_id,
scope="local",
status="available",
provider="local",
total=disk.total,
used=disk.used,
available=disk.free,
cache_seconds=self.cache_seconds,
)
config = source.get("config", {})
override_total = config.get("capacity_override_total_bytes")
override_available = config.get("capacity_override_available_bytes")
try:
override_total = int(override_total) if override_total is not None else None
override_available = int(override_available) if override_available is not None else None
except (TypeError, ValueError):
override_total = override_available = None
if (
override_total is not None
and override_available is not None
and override_total > 0
and 0 <= override_available <= override_total
):
return self._capacity_result(
source_id,
scope="remote",
status="available",
provider="manual",
total=override_total,
used=override_total - override_available,
available=override_available,
reason="管理员手工设置;不会自动随云盘变化",
cache_seconds=self.cache_seconds,
)
native_or_alist = bool(
config.get("storage_backend") == "openlist_native"
or config.get("mode") == "encrypted"
or config.get("driver") == "alist"
)
try:
values = self.rclone.about(source) if native_or_alist and self.rclone is not None else None
provider = "rclone" if values is not None else "webdav"
if values is None:
values = self._webdav_capacity(source)
except NotImplementedError:
values = None
provider = "rclone" if native_or_alist else "webdav"
except Exception as exc:
return self._capacity_result(
source_id,
scope="remote",
status="unreachable",
provider="rclone" if native_or_alist else "webdav",
reason=f"无法连接远端配额接口:{type(exc).__name__}",
cache_seconds=self.cache_seconds,
)
if values is None:
return self._capacity_result(
source_id,
scope="remote",
status="unsupported",
provider=provider,
reason="此媒体来源未提供容量配额",
cache_seconds=self.cache_seconds,
)
return self._capacity_result(
source_id,
scope="remote",
status="available",
provider=provider,
total=values["total_bytes"],
used=values["used_bytes"],
available=values["available_bytes"],
cache_seconds=self.cache_seconds,
)
def configure_capacity_override(
self,
source_id: str,
*,
total_bytes: int | None,
available_bytes: int | None,
) -> dict:
source = self.sources.get(source_id)
if source["kind"] == "local":
raise ValueError("本地媒体来源使用文件系统真实容量,不能手工覆盖")
if (total_bytes is None) != (available_bytes is None):
raise ValueError("总容量与可用容量必须同时填写或同时清除")
if total_bytes is not None and (total_bytes <= 0 or not 0 <= available_bytes <= total_bytes):
raise ValueError("可用容量必须大于等于 0 且不能超过总容量")
config = dict(source.get("config") or {})
if total_bytes is None:
config.pop("capacity_override_total_bytes", None)
config.pop("capacity_override_available_bytes", None)
else:
config["capacity_override_total_bytes"] = int(total_bytes)
config["capacity_override_available_bytes"] = int(available_bytes)
with self.db.transaction() as conn:
conn.execute(
"UPDATE sources SET config_json=?,updated_at=? WHERE id=?",
(json.dumps(config), utcnow(), source_id),
)
with self._lock:
self._capacity_cache.pop(source_id, None)
return self.capacity(source_id, refresh=True)
def capacity(self, source_id: str, *, refresh: bool = False) -> dict:
now = time.monotonic()
with self._lock:
cached = self._capacity_cache.get(source_id)
if not refresh and cached is not None and now - cached[0] < self.cache_seconds:
return dict(cached[1])
result = self._calculate_capacity(source_id)
with self._lock:
self._capacity_cache[source_id] = (time.monotonic(), dict(result))
return result
def _postgres_relation_bytes(self) -> tuple[int, int]:
with self.db.read() as conn:
database = conn.execute("SELECT pg_database_size(current_database())").fetchone()
vectors = conn.execute(
"SELECT coalesce(pg_total_relation_size('frames'),0)+"
"coalesce(pg_total_relation_size('faces'),0)+coalesce(pg_total_relation_size('people'),0)"
).fetchone()
return int(database[0] or 0), int(vectors[0] or 0)
def _openlist_staging_paths(self) -> list[dict]:
rows: list[dict] = []
try:
with self.db.read() as conn:
sources = conn.execute("SELECT id,name,config_json FROM sources WHERE kind='webdav'").fetchall()
except Exception:
return rows
for row in sources:
try:
config = json.loads(row["config_json"] or "{}")
except (TypeError, ValueError, json.JSONDecodeError):
continue
value = str(config.get("openlist_local_staging_path") or "").strip()
if value:
rows.append({"source_id": str(row["id"]), "name": str(row["name"]), "path": Path(value)})
return rows
@staticmethod
def _child_total(children: dict[str, dict]) -> tuple[int, int, int]:
total = active = reclaimable = 0
for value in children.values():
total += int(value.get("bytes") or 0)
active += int(value.get("active_bytes") or 0)
reclaimable += int(value.get("reclaimable_bytes") or 0)
return total, active, reclaimable
def _calculate(self) -> dict:
disk = shutil.disk_usage(self.settings.data_dir)
data_total = _path_size(self.settings.data_dir)
postgres_total, ai_index_bytes = self._postgres_relation_bytes()
database_bytes = max(0, postgres_total - ai_index_bytes)
models, models_ok, models_reason = _root_size(self.settings.models_dir, missing_available=True)
thumbnails, thumbnails_ok, thumbnails_reason = _root_size(
self.settings.thumbnails_dir, missing_available=True
)
preview, preview_ok, preview_reason = _root_size(self.settings.preview_dir, missing_available=True)
query_images, query_ok, query_reason = _root_size(
self.settings.uploads_dir, missing_available=True
)
remote_media, remote_media_ok, remote_media_reason = _root_size(
self.settings.remote_media_cache_dir, missing_available=True
)
upload_receive, upload_ok, upload_reason = _root_size(
self.settings.upload_staging_dir, missing_available=True
)
download_staging, download_ok, download_reason = _root_size(
self.settings.download_staging_dir, missing_available=True
)
encryption_work, encryption_ok, encryption_reason = _root_size(
self.settings.rclone_dir / "local-encryption", missing_available=True
)
rclone_status = self.rclone.status() if self.rclone is not None else {}
remote_status = self.remote_cache.status() if self.remote_cache is not None else {}
remote_children = {
"remote_media_cache": _entry(
remote_media,
available=remote_media_ok,
reason=remote_media_reason,
active_bytes=int(remote_status.get("active_bytes") or 0),
reclaimable_bytes=int(remote_status.get("evictable_bytes") or 0),
entries=int(remote_status.get("entries") or 0),
),
"rclone_vfs_cache": _entry(
int(rclone_status.get("vfs_cache_bytes") or 0),
active_bytes=int(rclone_status.get("active_cache_bytes") or 0),
reclaimable_bytes=int(rclone_status.get("reclaimable_cache_bytes") or 0),
instances=rclone_status.get("instances") or [],
),
"rclone_runtime": _entry(int(rclone_status.get("runtime_bytes") or 0)),
}
upload_children = {
"upload_receive": _entry(upload_receive, available=upload_ok, reason=upload_reason),
"download_staging": _entry(download_staging, available=download_ok, reason=download_reason),
"encryption_work": _entry(encryption_work, available=encryption_ok, reason=encryption_reason),
}
staging_rows = self._openlist_staging_paths()
staging_roots: list[Path] = []
staging_details: list[dict] = []
for item in staging_rows:
size, available, reason = _root_size(item["path"])
staging_details.append(
{
"source_id": item["source_id"],
"name": item["name"],
"path": str(item["path"]),
**_entry(size, available=available, reason=reason),
}
)
if available and not item["path"].is_symlink():
staging_roots.append(item["path"].resolve())
unique_staging = _dedupe_roots(staging_roots)
staging_size = sum(_path_size(path) for path in unique_staging)
upload_children["openlist_local_staging"] = _entry(
staging_size,
available=all(item.get("available", False) for item in staging_details)
if staging_details
else True,
reason="部分 OpenList 中转目录当前不可访问"
if any(not item.get("available", False) for item in staging_details)
else None,
locations=staging_details,
)
preview_total, preview_active, preview_reclaimable = self._child_total(
{
"preview_cache": _entry(preview, available=preview_ok, reason=preview_reason),
"query_images": _entry(query_images, available=query_ok, reason=query_reason),
}
)
remote_total, remote_active, remote_reclaimable = self._child_total(remote_children)
upload_total, upload_active, upload_reclaimable = self._child_total(upload_children)
categories: dict[str, dict] = {
"database": _entry(database_bytes),
"ai_index": _entry(ai_index_bytes, mode="pgvector"),
"models": _entry(models, available=models_ok, reason=models_reason),
"thumbnails": _entry(thumbnails, available=thumbnails_ok, reason=thumbnails_reason),
"preview_cache": _entry(
preview_total,
available=preview_ok and query_ok,
active_bytes=preview_active,
reclaimable_bytes=preview_reclaimable,
children={
"preview_cache": _entry(preview, available=preview_ok, reason=preview_reason),
"query_images": _entry(query_images, available=query_ok, reason=query_reason),
},
),
"remote_cache": _entry(
remote_total,
active_bytes=remote_active,
reclaimable_bytes=remote_reclaimable,
children=remote_children,
),
"upload_staging": _entry(
upload_total,
available=all(value.get("available", True) for value in upload_children.values()),
active_bytes=upload_active,
reclaimable_bytes=upload_reclaimable,
children=upload_children,
),
}
managed_roots = _dedupe_roots(
[
self.settings.models_dir,
self.settings.thumbnails_dir,
self.settings.preview_dir,
self.settings.uploads_dir,
self.settings.remote_media_cache_dir,
self.settings.rclone_dir,
self.settings.upload_staging_dir,
self.settings.download_staging_dir,
]
)
known_in_data = sum(
_path_size(path)
for path in managed_roots
if self.settings.data_dir in path.parents or path == self.settings.data_dir
)
categories["other"] = _entry(max(0, data_total - known_in_data))
external_staging = sum(
_path_size(path)
for path in unique_staging
if self.settings.data_dir not in path.parents and path != self.settings.data_dir
)
app_bytes = data_total + postgres_total + external_staging
return {
"schema_version": 2,
"collected_at": datetime.now(UTC).isoformat(),
"disk": {"total_bytes": disk.total, "used_bytes": disk.used, "available_bytes": disk.free},
"app_bytes": app_bytes,
"categories": categories,
"cached_seconds": self.cache_seconds,
}
def usage(self, *, refresh: bool = False) -> dict:
now = time.monotonic()
with self._lock:
if not refresh and self._cached is not None and now - self._cached_at < self.cache_seconds:
return self._cached
self._cached = self._calculate()
self._cached_at = now
return self._cached