| """Local media cache management — stats, list, clear, delete.""" |
|
|
| import asyncio |
| from pathlib import Path |
| from typing import Any, Literal |
|
|
| from fastapi import APIRouter, Query |
| from pydantic import BaseModel |
|
|
| from app.platform.config.snapshot import get_config |
| from app.platform.errors import AppError, ErrorKind |
| from app.platform.storage import ( |
| clear_local_media_files, |
| delete_local_media_file, |
| image_files_dir, |
| video_files_dir, |
| ) |
|
|
| router = APIRouter(prefix="/cache", tags=["Admin - Cache"]) |
|
|
| |
| |
| |
| _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} |
| _VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"} |
|
|
|
|
| class ClearCacheRequest(BaseModel): |
| type: Literal["image", "video"] = "image" |
|
|
|
|
| class DeleteCacheItemRequest(BaseModel): |
| type: Literal["image", "video"] = "image" |
| name: str |
|
|
|
|
| class DeleteCacheItemsRequest(BaseModel): |
| type: Literal["image", "video"] = "image" |
| names: list[str] |
|
|
|
|
| def _dir(media_type: str) -> Path: |
| return image_files_dir() if media_type == "image" else video_files_dir() |
|
|
|
|
| def _exts(media_type: str): |
| return _IMAGE_EXTS if media_type == "image" else _VIDEO_EXTS |
|
|
|
|
| def _limit_mb(media_type: str) -> int: |
| cfg = get_config() |
| return max(0, int(cfg.get_int(f"cache.local.{media_type}_max_mb", 0))) |
|
|
|
|
| def _stats(media_type: str) -> dict[str, Any]: |
| d = _dir(media_type) |
| files = [] |
| if d.exists(): |
| allowed = _exts(media_type) |
| files = [f for f in d.glob("*") if f.is_file() and f.suffix.lower() in allowed] |
|
|
| total_size = sum(f.stat().st_size for f in files) |
| limit_mb = _limit_mb(media_type) |
| limit_bytes = limit_mb * 1024 * 1024 |
| usage_ratio = (total_size / limit_bytes) if limit_bytes > 0 else None |
| usage_percent = round(usage_ratio * 100, 1) if usage_ratio is not None else None |
| return { |
| "count": len(files), |
| "size_mb": round(total_size / 1024 / 1024, 2), |
| "size_bytes": total_size, |
| "limit_mb": limit_mb, |
| "limit_bytes": limit_bytes, |
| "limited": limit_bytes > 0, |
| "usage_ratio": round(usage_ratio, 4) if usage_ratio is not None else None, |
| "usage_percent": usage_percent, |
| } |
|
|
|
|
| def _list_files(media_type: str, page: int, page_size: int) -> dict[str, Any]: |
| d = _dir(media_type) |
| if not d.exists(): |
| return {"total": 0, "page": page, "page_size": page_size, "items": []} |
| allowed = _exts(media_type) |
| files = sorted( |
| (f for f in d.glob("*") if f.is_file() and f.suffix.lower() in allowed), |
| key=lambda f: f.stat().st_mtime, |
| reverse=True, |
| ) |
| total = len(files) |
| start = (page - 1) * page_size |
| chunk = files[start : start + page_size] |
| items = [] |
| for f in chunk: |
| st = f.stat() |
| items.append({ |
| "name": f.name, |
| "size_bytes": st.st_size, |
| "modified_at": st.st_mtime, |
| }) |
| return {"total": total, "page": page, "page_size": page_size, "items": items} |
|
|
|
|
| |
| |
| |
|
|
| @router.get("") |
| async def cache_stats(): |
| return { |
| "local_image": _stats("image"), |
| "local_video": _stats("video"), |
| } |
|
|
|
|
| @router.get("/list") |
| async def list_local( |
| cache_type: Literal["image", "video"] = "image", |
| type_: Literal["image", "video"] | None = Query(default=None, alias="type"), |
| page: int = 1, |
| page_size: int = 1000, |
| ): |
| media_type = type_ or cache_type |
| return {"status": "success", **_list_files(media_type, page, page_size)} |
|
|
|
|
| @router.post("/clear") |
| async def clear_local(req: ClearCacheRequest): |
| removed = await asyncio.to_thread(clear_local_media_files, req.type) |
| return {"status": "success", "result": {"removed": removed}} |
|
|
|
|
| @router.post("/item/delete") |
| async def delete_local_item(req: DeleteCacheItemRequest): |
| if not req.name: |
| raise AppError( |
| "Missing file name", |
| kind=ErrorKind.VALIDATION, |
| code="missing_file_name", |
| status=400, |
| ) |
| try: |
| deleted = await asyncio.to_thread(delete_local_media_file, req.type, req.name) |
| except ValueError as exc: |
| raise AppError( |
| str(exc), |
| kind=ErrorKind.VALIDATION, |
| code="invalid_file_name", |
| status=400, |
| ) from exc |
| if not deleted: |
| raise AppError( |
| "File not found", |
| kind=ErrorKind.VALIDATION, |
| code="file_not_found", |
| status=404, |
| ) |
| return {"status": "success", "result": {"deleted": req.name}} |
|
|
|
|
| @router.post("/items/delete") |
| async def delete_local_items(req: DeleteCacheItemsRequest): |
| names = [name.strip() for name in req.names if name and name.strip()] |
| if not names: |
| raise AppError( |
| "Missing file names", |
| kind=ErrorKind.VALIDATION, |
| code="missing_file_names", |
| status=400, |
| ) |
|
|
| deleted = 0 |
| missing = 0 |
|
|
| for name in names: |
| try: |
| removed = await asyncio.to_thread(delete_local_media_file, req.type, name) |
| except ValueError: |
| missing += 1 |
| continue |
| if removed: |
| deleted += 1 |
| else: |
| missing += 1 |
|
|
| return { |
| "status": "success", |
| "result": { |
| "deleted": deleted, |
| "missing": missing, |
| }, |
| } |
|
|