Files
web-page-backend/travel-proxy/app/main.py

221 lines
5.9 KiB
Python

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/<filename>
- 없으면: 원본 폴더 album/.thumb/<filename>
"""
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/<filename> or album/.thumb/<filename>
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))