221 lines
5.9 KiB
Python
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))
|