refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API
This commit is contained in:
@@ -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")}
|
||||||
|
|||||||
Reference in New Issue
Block a user