Spaces:
Paused
Paused
| from __future__ import annotations | |
| import io | |
| import shutil | |
| import threading | |
| import time | |
| import zipfile | |
| from pathlib import Path | |
| from fastapi import HTTPException | |
| from fastapi.responses import FileResponse, Response | |
| from PIL import Image, ImageOps | |
| from services.config import config | |
| from services.image_storage_service import image_storage_service | |
| from services.image_tags_service import load_tags, remove_tags | |
| from utils.log import logger | |
| THUMBNAIL_SIZE = (320, 320) | |
| def _cleanup_empty_dirs(root: Path) -> None: | |
| for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True): | |
| try: | |
| path.rmdir() | |
| except OSError: | |
| pass | |
| def _safe_relative_path(path: str) -> str: | |
| value = str(path or "").strip().replace("\\", "/").lstrip("/") | |
| if not value: | |
| raise HTTPException(status_code=404, detail="image not found") | |
| parts = Path(value).parts | |
| if any(part in {"", ".", ".."} for part in parts): | |
| raise HTTPException(status_code=404, detail="image not found") | |
| return Path(*parts).as_posix() | |
| def _safe_image_path(relative_path: str) -> Path: | |
| rel = _safe_relative_path(relative_path) | |
| root = config.images_dir.resolve() | |
| path = (root / rel).resolve() | |
| try: | |
| path.relative_to(root) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=404, detail="image not found") from exc | |
| if not path.is_file(): | |
| raise HTTPException(status_code=404, detail="image not found") | |
| return path | |
| def get_image_response(relative_path: str) -> FileResponse | Response: | |
| headers = { | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Methods": "GET, OPTIONS", | |
| "Access-Control-Allow-Headers": "*", | |
| } | |
| if image_storage_service.has_local(relative_path): | |
| return FileResponse(_safe_image_path(relative_path), headers=headers) | |
| return Response(content=image_storage_service.get_bytes(relative_path), media_type="image/png", headers=headers) | |
| def _thumbnail_path(relative_path: str) -> Path: | |
| rel = _safe_relative_path(relative_path) | |
| return config.image_thumbnails_dir / f"{rel}.png" | |
| def thumbnail_url(base_url: str, relative_path: str) -> str: | |
| return f"{base_url.rstrip('/')}/image-thumbnails/{_safe_relative_path(relative_path)}" | |
| def _image_dimensions(path: Path) -> tuple[int, int] | None: | |
| try: | |
| with Image.open(path) as image: | |
| return image.size | |
| except Exception: | |
| return None | |
| def ensure_thumbnail(relative_path: str) -> Path: | |
| target = _thumbnail_path(relative_path) | |
| source_mtime = 0.0 | |
| source: Path | None = None | |
| if image_storage_service.has_local(relative_path): | |
| source = _safe_image_path(relative_path) | |
| source_mtime = source.stat().st_mtime | |
| if target.exists() and (not source_mtime or target.stat().st_mtime >= source_mtime): | |
| return target | |
| target.parent.mkdir(parents=True, exist_ok=True) | |
| try: | |
| image_source = source if source is not None else io.BytesIO(image_storage_service.get_bytes(relative_path)) | |
| with Image.open(image_source) as image: | |
| image = ImageOps.exif_transpose(image) | |
| if image.mode not in {"RGB", "RGBA"}: | |
| image = image.convert("RGBA" if "A" in image.getbands() else "RGB") | |
| image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS) | |
| image.save(target, format="PNG", optimize=True) | |
| except HTTPException: | |
| raise | |
| except Exception as exc: | |
| raise HTTPException(status_code=422, detail="failed to create thumbnail") from exc | |
| return target | |
| def get_thumbnail_response(relative_path: str) -> FileResponse: | |
| headers = { | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Methods": "GET, OPTIONS", | |
| "Access-Control-Allow-Headers": "*", | |
| } | |
| return FileResponse(ensure_thumbnail(relative_path), headers=headers) | |
| def get_image_download_response(relative_path: str) -> FileResponse: | |
| cors_headers = { | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Methods": "GET, OPTIONS", | |
| "Access-Control-Allow-Headers": "*", | |
| } | |
| if image_storage_service.has_local(relative_path): | |
| path = _safe_image_path(relative_path) | |
| headers = {**cors_headers, "Content-Disposition": f'attachment; filename="{path.name}"'} | |
| return FileResponse(path, filename=path.name, headers=headers) | |
| rel = _safe_relative_path(relative_path) | |
| headers = { | |
| **cors_headers, | |
| "Content-Disposition": f'attachment; filename="{Path(rel).name}"', | |
| } | |
| return Response( | |
| content=image_storage_service.get_bytes(rel), | |
| media_type="image/png", | |
| headers=headers, | |
| ) | |
| def cleanup_image_thumbnails() -> int: | |
| thumbnails_root = config.image_thumbnails_dir | |
| removed = 0 | |
| for path in thumbnails_root.rglob("*"): | |
| if not path.is_file(): | |
| continue | |
| rel = path.relative_to(thumbnails_root).as_posix() | |
| if not rel.endswith(".png") or not image_storage_service.exists(rel[:-4]): | |
| path.unlink() | |
| removed += 1 | |
| _cleanup_empty_dirs(thumbnails_root) | |
| return removed | |
| def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]: | |
| config.cleanup_old_images() | |
| cleanup_image_thumbnails() | |
| all_tags = load_tags() | |
| items = [ | |
| { | |
| **item, | |
| "url": str(item.get("url") or f"{base_url.rstrip('/')}/images/{item['path']}"), | |
| "thumbnail_url": thumbnail_url(base_url, str(item["path"])), | |
| "tags": all_tags.get(str(item["path"]), []), | |
| } | |
| for item in image_storage_service.list_items(base_url, start_date, end_date) | |
| ] | |
| groups: dict[str, list[dict[str, object]]] = {} | |
| for item in items: | |
| groups.setdefault(str(item["date"]), []).append(item) | |
| return {"items": items, "groups": [{"date": key, "items": value} for key, value in groups.items()]} | |
| def delete_images(paths: list[str] | None = None, start_date: str = "", end_date: str = "", all_matching: bool = False) -> dict[str, int]: | |
| root = config.images_dir.resolve() | |
| targets = [ | |
| str(item["path"]) | |
| for item in image_storage_service.list_items("", start_date=start_date, end_date=end_date) | |
| ] if all_matching else (paths or []) | |
| removed = 0 | |
| for item in targets: | |
| path = (root / item).resolve() | |
| try: | |
| path.relative_to(root) | |
| except ValueError: | |
| continue | |
| if image_storage_service.delete(item): | |
| removed += 1 | |
| for thumbnail in (_thumbnail_path(item), config.image_thumbnails_dir / _safe_relative_path(item)): | |
| if thumbnail.is_file(): | |
| thumbnail.unlink() | |
| remove_tags(item) | |
| _cleanup_empty_dirs(root) | |
| _cleanup_empty_dirs(config.image_thumbnails_dir) | |
| return {"removed": removed} | |
| def download_images_zip(paths: list[str]) -> io.BytesIO: | |
| root = config.images_dir.resolve() | |
| buf = io.BytesIO() | |
| added = 0 | |
| used_names: set[str] = set() | |
| with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: | |
| for item in paths: | |
| rel = _safe_relative_path(item) | |
| path = (root / rel).resolve() | |
| payload: bytes | None = None | |
| try: | |
| path.relative_to(root) | |
| except ValueError: | |
| continue | |
| if path.is_file(): | |
| payload = path.read_bytes() | |
| else: | |
| try: | |
| payload = image_storage_service.get_bytes(rel) | |
| except Exception: | |
| continue | |
| name = path.name | |
| if name in used_names: | |
| stem = path.stem | |
| suffix = path.suffix | |
| counter = 2 | |
| while f"{stem}_{counter}{suffix}" in used_names: | |
| counter += 1 | |
| name = f"{stem}_{counter}{suffix}" | |
| used_names.add(name) | |
| zf.writestr(name, payload) | |
| added += 1 | |
| if added == 0: | |
| raise HTTPException(status_code=404, detail="no images found") | |
| buf.seek(0) | |
| return buf | |
| def storage_stats() -> dict: | |
| import shutil | |
| usage = shutil.disk_usage(config.images_dir) | |
| total_mb = usage.total // (1024 * 1024) | |
| used_mb = usage.used // (1024 * 1024) | |
| free_mb = usage.free // (1024 * 1024) | |
| image_count = 0 | |
| image_size = 0 | |
| for p in config.images_dir.rglob("*"): | |
| if p.is_file(): | |
| image_count += 1 | |
| image_size += p.stat().st_size | |
| return { | |
| "disk_total_mb": total_mb, | |
| "disk_used_mb": used_mb, | |
| "disk_free_mb": free_mb, | |
| "image_count": image_count, | |
| "image_size_mb": image_size // (1024 * 1024), | |
| "image_size_bytes": image_size, | |
| } | |
| def compress_images(quality: int = 60) -> dict: | |
| """重新压缩所有图片,返回节省的空间""" | |
| saved = 0 | |
| count = 0 | |
| for p in sorted(config.images_dir.rglob("*.png")): | |
| if not p.is_file(): | |
| continue | |
| try: | |
| orig = p.stat().st_size | |
| with Image.open(p) as img: | |
| img = ImageOps.exif_transpose(img) | |
| img.save(str(p) + ".tmp", format="PNG", optimize=True) | |
| new_size = Path(str(p) + ".tmp").stat().st_size | |
| if new_size < orig: | |
| Path(str(p) + ".tmp").replace(p) | |
| saved += orig - new_size | |
| count += 1 | |
| else: | |
| Path(str(p) + ".tmp").unlink() | |
| except Exception: | |
| pass | |
| return {"compressed": count, "saved_bytes": saved, "saved_mb": saved // (1024 * 1024)} | |
| def delete_to_target(target_free_mb: int, dry_run: bool = False) -> dict: | |
| """删除最旧的图片直到剩余空间达到 target_free_mb""" | |
| import shutil | |
| usage = shutil.disk_usage(config.images_dir) | |
| current_free = usage.free // (1024 * 1024) | |
| if current_free >= target_free_mb and not dry_run: | |
| return {"removed": 0, "current_free_mb": current_free, "target_free_mb": target_free_mb, "done": True} | |
| files = sorted( | |
| (p for p in config.images_dir.rglob("*.png") if p.is_file()), | |
| key=lambda p: p.stat().st_mtime, | |
| ) | |
| removed = 0 | |
| freed = 0 | |
| for p in files: | |
| if current_free + freed // (1024 * 1024) >= target_free_mb: | |
| break | |
| size = p.stat().st_size | |
| if not dry_run: | |
| rel = p.relative_to(config.images_dir).as_posix() | |
| for tp in (_thumbnail_path(rel), config.image_thumbnails_dir / _safe_relative_path(rel)): | |
| if tp.is_file(): | |
| tp.unlink() | |
| remove_tags(rel) | |
| p.unlink() | |
| freed += size | |
| removed += 1 | |
| if not dry_run: | |
| _cleanup_empty_dirs(config.images_dir) | |
| _cleanup_empty_dirs(config.image_thumbnails_dir) | |
| return { | |
| "removed": removed, | |
| "freed_mb": freed // (1024 * 1024), | |
| "target_free_mb": target_free_mb, | |
| "current_free_mb": current_free + (freed // (1024 * 1024)), | |
| "done": (current_free + freed // (1024 * 1024)) >= target_free_mb, | |
| "dry_run": dry_run, | |
| } | |
| def download_images_zip(paths: list[str]) -> io.BytesIO: | |
| root = config.images_dir.resolve() | |
| buf = io.BytesIO() | |
| added = 0 | |
| used_names: set[str] = set() | |
| with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: | |
| for item in paths: | |
| rel = _safe_relative_path(item) | |
| path = (root / rel).resolve() | |
| try: | |
| path.relative_to(root) | |
| except ValueError: | |
| continue | |
| if not path.is_file(): | |
| continue | |
| name = path.name | |
| if name in used_names: | |
| stem = path.stem | |
| suffix = path.suffix | |
| counter = 2 | |
| while f"{stem}_{counter}{suffix}" in used_names: | |
| counter += 1 | |
| name = f"{stem}_{counter}{suffix}" | |
| used_names.add(name) | |
| zf.write(path, name) | |
| added += 1 | |
| if added == 0: | |
| raise HTTPException(status_code=404, detail="no images found") | |
| buf.seek(0) | |
| return buf | |
| def _auto_cleanup_worker(stop_event: threading.Event) -> None: | |
| """后台线程:每30分钟检查存储,空间低于阈值自动清理最旧图片""" | |
| import shutil | |
| min_free_mb = getattr(config, "image_min_free_mb", None) | |
| if min_free_mb is None: | |
| min_free_mb = 500 | |
| while not stop_event.wait(1800): # 每30分钟 | |
| try: | |
| config.cleanup_old_images() | |
| cleanup_image_thumbnails() | |
| usage = shutil.disk_usage(config.images_dir) | |
| free_mb = usage.free // (1024 * 1024) | |
| if free_mb < min_free_mb: | |
| logger.info({"event": "image_auto_cleanup", "free_mb": free_mb, "min_free_mb": min_free_mb}) | |
| result = delete_to_target(min_free_mb) | |
| logger.info({"event": "image_auto_cleanup_done", **result}) | |
| except Exception: | |
| pass | |
| def start_image_cleanup_scheduler(stop_event: threading.Event) -> threading.Thread: | |
| t = threading.Thread(target=_auto_cleanup_worker, args=(stop_event,), daemon=True, name="image-cleanup") | |
| t.start() | |
| return t | |