refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API

This commit is contained in:
2026-04-24 09:06:27 +09:00
parent e82ff83a5f
commit 7d78fae77f

View File

@@ -1,14 +1,19 @@
import os import os
import json import json
import time import logging
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List from typing import Any, List
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel
from PIL import Image from PIL import Image
from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, get_album_cover
from .indexer import sync
logger = logging.getLogger(__name__)
app = FastAPI() app = FastAPI()
# ----------------------------- # -----------------------------
@@ -26,32 +31,16 @@ THUMB_SIZE = (480, 480)
THUMB_ROOT.mkdir(parents=True, exist_ok=True) 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"} IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
# ----------------------------- # -----------------------------
# Cache # DB init
# ----------------------------- # -----------------------------
CACHE: Dict[str, Dict[str, Any]] = {} init_db()
CACHE_TTL = int(os.getenv("TRAVEL_CACHE_TTL", "300"))
META_MTIME_CACHE: Dict[str, float] = {}
# ----------------------------- # -----------------------------
# Helpers # Helpers
# ----------------------------- # -----------------------------
def _file_mtime(p: Path) -> float:
try:
return p.stat().st_mtime
except FileNotFoundError:
return 0.0
def _read_json(path: Path) -> Any: def _read_json(path: Path) -> Any:
if not path.exists(): if not path.exists():
raise HTTPException(500, f"Missing required file: {path}") raise HTTPException(500, f"Missing required file: {path}")
@@ -67,57 +56,27 @@ def load_regions_geojson() -> dict:
return _read_json(REGIONS_GEOJSON_PATH) 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]: def _get_albums_for_region(region: str, region_map: dict) -> List[str]:
if region not in region_map: if region not in region_map:
raise HTTPException(400, "Unknown region") raise HTTPException(400, "Unknown region")
v = region_map[region] v = region_map[region]
if isinstance(v, list): if isinstance(v, list):
return v return v
if isinstance(v, dict) and isinstance(v.get("albums"), list): if isinstance(v, dict) and isinstance(v.get("albums"), list):
return v["albums"] return v["albums"]
raise HTTPException(500, "Invalid region_map format") raise HTTPException(500, "Invalid region_map format")
def _thumb_path_for(src: Path, album: str) -> Path: def _ensure_thumb_fallback(src: Path, album: str) -> Path:
""" """온디맨드 썸네일 폴백 (sync 누락 분 대응)."""
썸네일 저장 위치 결정. out = THUMB_ROOT / album / src.name
- 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(): if out.exists():
return out return out
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
# ✅ 확장자 유지: IMG_3281.tmp.JPG (끝이 .JPG로 끝나게)
tmp = out.with_name(out.stem + ".tmp" + out.suffix) tmp = out.with_name(out.stem + ".tmp" + out.suffix)
try: try:
with Image.open(src) as im: with Image.open(src) as im:
im.thumbnail(THUMB_SIZE) im.thumbnail(THUMB_SIZE)
# ✅ 확장자 기준으로 포맷 명시 (대문자 .JPG도 대응)
ext = out.suffix.lower() ext = out.suffix.lower()
if ext in (".jpg", ".jpeg"): if ext in (".jpg", ".jpeg"):
fmt = "JPEG" fmt = "JPEG"
@@ -126,44 +85,24 @@ def ensure_thumb(src: Path, album: str) -> Path:
elif ext == ".webp": elif ext == ".webp":
fmt = "WEBP" fmt = "WEBP"
else: else:
# 혹시 모를 경우: Pillow가 읽은 포맷을 사용
fmt = (im.format or "").upper() or "JPEG" fmt = (im.format or "").upper() or "JPEG"
im.save(tmp, format=fmt, quality=85, optimize=True) im.save(tmp, format=fmt, quality=85, optimize=True)
# ✅ 생성 완료 후 교체
tmp.replace(out) tmp.replace(out)
return out return out
finally: finally:
# 실패 시 tmp 찌꺼기 정리
try: try:
if tmp.exists(): if tmp.exists():
tmp.unlink() tmp.unlink()
except Exception: except Exception:
pass pass
def scan_album(album: str) -> List[Dict[str, Any]]:
album_dir = ROOT / album
if not album_dir.exists():
return []
items = [] # -----------------------------
# os.scandir이 iterdir보다 빠름 (파일 속성 한 번에 가져옴) # Models
with os.scandir(album_dir) as entries: # -----------------------------
for entry in entries: class CoverRequest(BaseModel):
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT: filename: str
# ⚡ 성능 핵심: 여기서 썸네일 생성(ensure_thumb) 절대 하지 않음!
# 파일 존재 여부만 확인하고 바로 리턴
items.append({
"album": album,
"file": entry.name,
"url": f"{MEDIA_BASE}/{album}/{entry.name}",
"thumb": f"{MEDIA_BASE}/.thumb/{album}/{entry.name}",
# 정렬을 위해 수정시간/이름 필요하면 여기서 저장
"mtime": entry.stat().st_mtime
})
return items
# ----------------------------- # -----------------------------
# Routes # Routes
@@ -172,18 +111,11 @@ def scan_album(album: str) -> List[Dict[str, Any]]:
def health(): def health():
return {"status": "healthy", "service": "travel-proxy"} return {"status": "healthy", "service": "travel-proxy"}
@app.get("/api/travel/regions") @app.get("/api/travel/regions")
def regions(): def regions():
_meta_changed_invalidate_cache()
return load_regions_geojson() return load_regions_geojson()
@app.post("/api/travel/reload")
def reload_cache():
"""강제로 캐시를 비워서 새로고침"""
CACHE.clear()
META_MTIME_CACHE.clear()
return {"ok": True, "message": "Cache cleared"}
@app.get("/api/travel/photos") @app.get("/api/travel/photos")
def photos( def photos(
@@ -191,53 +123,67 @@ def photos(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100), size: int = Query(20, ge=1, le=100),
): ):
_meta_changed_invalidate_cache() region_map = load_region_map()
albums = _get_albums_for_region(region, region_map)
result = get_photos_by_region(albums, page, size)
# 1. 캐시 키에 페이지 정보 포함하지 않음 (전체 리스트 캐싱 후 슬라이싱 전략) # URL 조합 (DB에는 경로를 저장하지 않음)
# 왜냐하면 파일 스캔 비용이 크지, 메모리 슬라이싱 비용은 작기 때문. items = []
now = time.time() for row in result["items"]:
cached = CACHE.get(region) items.append({
"album": row["album"],
if not cached or now - cached["ts"] >= CACHE_TTL: "file": row["filename"],
region_map = load_region_map() "url": f"{MEDIA_BASE}/{row['album']}/{row['filename']}",
albums = _get_albums_for_region(region, region_map) "thumb": f"{MEDIA_BASE}/.thumb/{row['album']}/{row['filename']}",
"mtime": row["mtime"],
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"]))
cached_data = {
"region": region,
"matched_albums": matched,
"items": all_items,
"total": len(all_items),
}
CACHE[region] = {"ts": now, "data": cached_data}
else:
cached_data = cached["data"]
# 2. 페이지네이션 슬라이싱
all_items = cached_data["items"]
total = len(all_items)
start = (page - 1) * size
end = start + size
paged_items = all_items[start:end]
return { return {
"region": region, "region": region,
"page": page, "page": page,
"size": size, "size": size,
"total": total, "total": result["total"],
"has_next": end < total, "has_next": result["has_next"],
"items": paged_items, "items": items,
"matched_albums": result["matched_albums"],
}
@app.post("/api/travel/sync")
def sync_endpoint():
result = sync(
travel_root=ROOT,
thumb_root=THUMB_ROOT,
region_map_path=REGION_MAP_PATH,
)
return result
@app.get("/api/travel/albums")
def albums_list():
rows = get_all_albums()
result = []
for r in rows:
cover = r["cover_filename"]
result.append({
"album": r["album"],
"count": r["count"],
"cover_url": f"{MEDIA_BASE}/{r['album']}/{cover}",
"cover_thumb": f"{MEDIA_BASE}/.thumb/{r['album']}/{cover}",
})
return result
@app.put("/api/travel/albums/{album}/cover")
def set_cover(album: str, body: CoverRequest):
ok = set_album_cover(album, body.filename)
if not ok:
raise HTTPException(404, f"Photo not found: {album}/{body.filename}")
return {
"album": album,
"filename": body.filename,
"cover_url": f"{MEDIA_BASE}/{album}/{body.filename}",
"cover_thumb": f"{MEDIA_BASE}/.thumb/{album}/{body.filename}",
} }
@@ -245,19 +191,17 @@ def photos(
def get_thumb(album: str, filename: str): def get_thumb(album: str, filename: str):
if ".." in album or ".." in filename: if ".." in album or ".." in filename:
raise HTTPException(400, "Invalid path") raise HTTPException(400, "Invalid path")
src = (ROOT / album / filename).resolve() src = (ROOT / album / filename).resolve()
if not str(src).startswith(str(ROOT)): if not str(src).startswith(str(ROOT)):
raise HTTPException(403, "Access denied") raise HTTPException(403, "Access denied")
if not src.exists() or not src.is_file(): if not src.exists() or not src.is_file():
raise HTTPException(404, "Source not found") raise HTTPException(404, "Source not found")
p = _ensure_thumb_fallback(src, album)
p = ensure_thumb(src, album)
if not p.exists() or not p.is_file(): if not p.exists() or not p.is_file():
raise HTTPException(404, "Thumbnail not found") raise HTTPException(404, "Thumbnail not found")
return FileResponse(str(p)) return FileResponse(str(p))
@app.get("/api/version") @app.get("/api/version")
def version(): def version():
import os
return {"version": os.getenv("APP_VERSION", "dev")} return {"version": os.getenv("APP_VERSION", "dev")}