"""画廊服务:扫描 output/ 目录结构。 output/ 下的每个一级子目录 = 一个画廊,目录名 = 谁生成的图片(profile_name)。 """ from __future__ import annotations import json from datetime import datetime from pathlib import Path from typing import Any from .. import config, db from ..providers import ProviderError def _folder(profile_name: str) -> Path: if not config.PROFILE_RE.fullmatch(profile_name): raise ProviderError("无效的画廊名称", status=400) root = config.OUTPUT_DIR.resolve() folder = (root / profile_name).resolve() if not folder.is_relative_to(root): raise ProviderError("画廊路径超出输出目录", status=400) return folder def _meta_by_file() -> dict[tuple[str, str], dict[str, Any]]: """从 generations 表建立 (profile, filename) → 元信息的索引。""" index: dict[tuple[str, str], dict[str, Any]] = {} with db.session() as conn: rows = conn.execute("SELECT * FROM generations ORDER BY created_at DESC").fetchall() for row in rows: try: files = json.loads(row["files"] or "[]") except ValueError: files = [] for name in files: key = (row["profile_name"], name) index.setdefault( key, { "model": row["model"], "provider_name": row["provider_name"], "created_at": row["created_at"], "prompt": row["prompt"], }, ) return index def _images_in(folder: Path) -> list[Path]: return sorted( (p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in config.IMAGE_SUFFIXES), key=lambda p: p.stat().st_mtime, reverse=True, ) def list_galleries() -> list[dict[str, Any]]: config.ensure_dirs() meta = _meta_by_file() galleries: list[dict[str, Any]] = [] for folder in sorted(config.OUTPUT_DIR.iterdir()): if not folder.is_dir(): continue images = _images_in(folder) if not images: continue total_bytes = 0 models: list[str] = [] latest = 0.0 for img in images: stat = img.stat() total_bytes += stat.st_size latest = max(latest, stat.st_mtime) info = meta.get((folder.name, img.name)) if info and info["model"] and info["model"] not in models: models.append(info["model"]) cover = images[0] galleries.append( { "name": folder.name, "count": len(images), "bytes": total_bytes, "updated_at": datetime.fromtimestamp(latest).strftime("%Y-%m-%d %H:%M"), "cover": f"/output/{folder.name}/{cover.name}", "models": models, } ) galleries.sort(key=lambda g: g["updated_at"], reverse=True) return galleries def list_gallery(profile_name: str) -> list[dict[str, Any]]: config.ensure_dirs() folder = _folder(profile_name) if not folder.is_dir(): return [] meta = _meta_by_file() items: list[dict[str, Any]] = [] for img in _images_in(folder): stat = img.stat() info = meta.get((profile_name, img.name), {}) items.append( { "profile_name": profile_name, "filename": img.name, "url": f"/output/{profile_name}/{img.name}", "bytes": stat.st_size, "model": info.get("model", "—"), "provider_name": info.get("provider_name", "—"), "prompt": info.get("prompt", ""), "created_at": info.get("created_at") or datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"), } ) return items def delete_gallery_item(profile_name: str, filename: str) -> None: folder = _folder(profile_name) if Path(filename).name != filename or "\\" in filename: raise ProviderError("无效的文件名", status=400) target = folder / filename if not target.resolve().is_relative_to(folder): raise ProviderError("图片路径超出画廊目录", status=400) if not target.is_file(): raise FileNotFoundError(f"文件不存在: {profile_name}/{filename}") target.unlink() # 同步 generations 记录 with db.session() as conn: rows = conn.execute( "SELECT id, files FROM generations WHERE profile_name=?", (profile_name,) ).fetchall() for row in rows: try: files = json.loads(row["files"] or "[]") except ValueError: files = [] if filename in files: files = [f for f in files if f != filename] conn.execute( "UPDATE generations SET files=? WHERE id=?", (json.dumps(files, ensure_ascii=False), row["id"]), )