diff --git a/travel-proxy/app/main.py b/travel-proxy/app/main.py index 7b12c3f..7ca8ea3 100644 --- a/travel-proxy/app/main.py +++ b/travel-proxy/app/main.py @@ -1,14 +1,19 @@ import os import json -import time +import logging from pathlib import Path -from typing import Dict, Any, List +from typing import Any, List from fastapi import FastAPI, HTTPException, Query -from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse +from pydantic import BaseModel 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() # ----------------------------- @@ -26,32 +31,16 @@ 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 +# DB init # ----------------------------- -CACHE: Dict[str, Dict[str, Any]] = {} -CACHE_TTL = int(os.getenv("TRAVEL_CACHE_TTL", "300")) -META_MTIME_CACHE: Dict[str, float] = {} +init_db() # ----------------------------- # 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}") @@ -67,57 +56,27 @@ 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/ - +def _ensure_thumb_fallback(src: Path, album: str) -> Path: + """온디맨드 썸네일 폴백 (sync 누락 분 대응).""" + out = THUMB_ROOT / album / src.name 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" @@ -126,44 +85,24 @@ def ensure_thumb(src: Path, album: str) -> Path: 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 = [] - # os.scandir이 iterdir보다 빠름 (파일 속성 한 번에 가져옴) - with os.scandir(album_dir) as entries: - for entry in entries: - if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT: - # ⚡ 성능 핵심: 여기서 썸네일 생성(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 +# ----------------------------- +# Models +# ----------------------------- +class CoverRequest(BaseModel): + filename: str + # ----------------------------- # Routes @@ -172,18 +111,11 @@ def scan_album(album: str) -> List[Dict[str, Any]]: def health(): return {"status": "healthy", "service": "travel-proxy"} + @app.get("/api/travel/regions") def regions(): - _meta_changed_invalidate_cache() 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") def photos( @@ -191,53 +123,67 @@ def photos( page: int = Query(1, ge=1), 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. 캐시 키에 페이지 정보 포함하지 않음 (전체 리스트 캐싱 후 슬라이싱 전략) - # 왜냐하면 파일 스캔 비용이 크지, 메모리 슬라이싱 비용은 작기 때문. - now = time.time() - cached = CACHE.get(region) - - if not cached or now - cached["ts"] >= CACHE_TTL: - 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"])) - - 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] + # URL 조합 (DB에는 경로를 저장하지 않음) + items = [] + for row in result["items"]: + items.append({ + "album": row["album"], + "file": row["filename"], + "url": f"{MEDIA_BASE}/{row['album']}/{row['filename']}", + "thumb": f"{MEDIA_BASE}/.thumb/{row['album']}/{row['filename']}", + "mtime": row["mtime"], + }) return { "region": region, "page": page, "size": size, - "total": total, - "has_next": end < total, - "items": paged_items, + "total": result["total"], + "has_next": result["has_next"], + "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): if ".." in album or ".." in filename: raise HTTPException(400, "Invalid path") - src = (ROOT / album / filename).resolve() if not str(src).startswith(str(ROOT)): raise HTTPException(403, "Access denied") if not src.exists() or not src.is_file(): raise HTTPException(404, "Source not found") - - p = ensure_thumb(src, album) + p = _ensure_thumb_fallback(src, album) if not p.exists() or not p.is_file(): raise HTTPException(404, "Thumbnail not found") return FileResponse(str(p)) + @app.get("/api/version") def version(): - import os return {"version": os.getenv("APP_VERSION", "dev")}