import os import json import time from pathlib import Path from typing import Dict, Any, List from fastapi import FastAPI, HTTPException, Query from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from PIL import Image app = FastAPI() # ----------------------------- # Env / Paths # ----------------------------- ROOT = Path(os.getenv("TRAVEL_ROOT", "/data/travel")).resolve() MEDIA_BASE = os.getenv("TRAVEL_MEDIA_BASE", "/media/travel") META_DIR = ROOT / "_meta" REGION_MAP_PATH = META_DIR / "region_map.json" REGIONS_GEOJSON_PATH = META_DIR / "regions.geojson" THUMB_ROOT = Path(os.getenv("TRAVEL_THUMB_ROOT", "/data/thumbs")).resolve() THUMB_SIZE = (480, 480) THUMB_ROOT.mkdir(parents=True, exist_ok=True) # 썸네일 정적 서빙 app.mount( f"{MEDIA_BASE}/.thumb", StaticFiles(directory=THUMB_ROOT), name="travel-thumbs", ) IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"} # ----------------------------- # Cache # ----------------------------- CACHE: Dict[str, Dict[str, Any]] = {} CACHE_TTL = int(os.getenv("TRAVEL_CACHE_TTL", "300")) META_MTIME_CACHE: Dict[str, float] = {} # ----------------------------- # Helpers # ----------------------------- def _file_mtime(p: Path) -> float: try: return p.stat().st_mtime except FileNotFoundError: return 0.0 def _read_json(path: Path) -> Any: if not path.exists(): raise HTTPException(500, f"Missing required file: {path}") with open(path, "r", encoding="utf-8") as f: return json.load(f) def load_region_map() -> dict: return _read_json(REGION_MAP_PATH) def load_regions_geojson() -> dict: return _read_json(REGIONS_GEOJSON_PATH) def _meta_changed_invalidate_cache(): cur = _file_mtime(REGION_MAP_PATH) + _file_mtime(REGIONS_GEOJSON_PATH) if META_MTIME_CACHE.get("meta") != cur: CACHE.clear() META_MTIME_CACHE["meta"] = cur def _get_albums_for_region(region: str, region_map: dict) -> List[str]: if region not in region_map: raise HTTPException(400, "Unknown region") v = region_map[region] if isinstance(v, list): return v if isinstance(v, dict) and isinstance(v.get("albums"), list): return v["albums"] raise HTTPException(500, "Invalid region_map format") def _thumb_path_for(src: Path, album: str) -> Path: """ 썸네일 저장 위치 결정. - TRAVEL_THUMB_ROOT가 있으면: THUMB_ROOT/album/ - 없으면: 원본 폴더 album/.thumb/ """ if THUMB_ROOT: base = THUMB_ROOT / album base.mkdir(parents=True, exist_ok=True) return base / src.name thumb_dir = src.parent / ".thumb" thumb_dir.mkdir(exist_ok=True) return thumb_dir / src.name def ensure_thumb(src: Path, album: str) -> Path: out = _thumb_path_for(src, album) # THUMB_ROOT/album/ or album/.thumb/ if out.exists(): return out out.parent.mkdir(parents=True, exist_ok=True) # ✅ 확장자 유지: IMG_3281.tmp.JPG (끝이 .JPG로 끝나게) tmp = out.with_name(out.stem + ".tmp" + out.suffix) try: with Image.open(src) as im: im.thumbnail(THUMB_SIZE) # ✅ 확장자 기준으로 포맷 명시 (대문자 .JPG도 대응) ext = out.suffix.lower() if ext in (".jpg", ".jpeg"): fmt = "JPEG" elif ext == ".png": fmt = "PNG" elif ext == ".webp": fmt = "WEBP" else: # 혹시 모를 경우: Pillow가 읽은 포맷을 사용 fmt = (im.format or "").upper() or "JPEG" im.save(tmp, format=fmt, quality=85, optimize=True) # ✅ 생성 완료 후 교체 tmp.replace(out) return out finally: # 실패 시 tmp 찌꺼기 정리 try: if tmp.exists(): tmp.unlink() except Exception: pass def scan_album(album: str) -> List[Dict[str, Any]]: album_dir = ROOT / album if not album_dir.exists(): return [] items = [] for p in album_dir.iterdir(): if p.is_file() and p.suffix.lower() in IMAGE_EXT: # ✅ 썸네일 생성 보장 ensure_thumb(p, album) items.append({ "album": album, "file": p.name, "url": f"{MEDIA_BASE}/{album}/{p.name}", "thumb": f"{MEDIA_BASE}/.thumb/{album}/{p.name}", }) return items # ----------------------------- # Routes # ----------------------------- @app.get("/api/travel/regions") def regions(): _meta_changed_invalidate_cache() return load_regions_geojson() @app.get("/api/travel/photos") def photos( region: str = Query(...), limit: int = Query(500, le=5000), ): _meta_changed_invalidate_cache() now = time.time() cached = CACHE.get(region) if cached and now - cached["ts"] < CACHE_TTL: return cached["data"] region_map = load_region_map() albums = _get_albums_for_region(region, region_map) all_items = [] matched = [] for album in albums: items = scan_album(album) matched.append({"album": album, "count": len(items)}) all_items.extend(items) all_items.sort(key=lambda x: (x["album"], x["file"])) data = { "region": region, "matched_albums": matched, "items": all_items[:limit], "total": len(all_items), "cached_at": int(now), "cache_ttl": CACHE_TTL, } CACHE[region] = {"ts": now, "data": data} return data @app.get("/media/travel/.thumb/{album}/{filename}") def get_thumb(album: str, filename: str): src = (ROOT / album / filename).resolve() if not p.exists() or not p.is_file(): raise HTTPException(404, "Thumbnail not found") # src로부터 thumb 생성/확인 (원본 확장자 유지) p = ensure_thumb(src, album) return FileResponse(str(p)) @app.get("/api/version") def version(): import os return {"version": os.getenv("APP_VERSION", "dev")}