docs: travel-proxy 성능 개선 구현 계획 — 5 Tasks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
681
docs/superpowers/plans/2026-04-24-travel-proxy-perf.md
Normal file
681
docs/superpowers/plans/2026-04-24-travel-proxy-perf.md
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
# Travel-Proxy 성능 개선 구현 계획
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** travel-proxy의 os.scandir 기반 아키텍처를 SQLite 인덱스 DB로 전환하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
|
||||||
|
|
||||||
|
**Architecture:** 기존 main.py의 스캔/캐시/썸네일 로직을 db.py(스키마+쿼리)와 indexer.py(동기화+썸네일)로 분리. main.py는 라우트만 담당. DB 경로는 `/data/thumbs/travel.db`.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, FastAPI, SQLite (표준 라이브러리 sqlite3), Pillow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 파일 구조
|
||||||
|
|
||||||
|
| 파일 | 역할 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| `travel-proxy/app/db.py` | SQLite 스키마 정의, 커넥션 헬퍼, 쿼리 함수 | 신규 |
|
||||||
|
| `travel-proxy/app/indexer.py` | 폴더 스캔 → DB 동기화 + 썸네일 일괄 생성 | 신규 |
|
||||||
|
| `travel-proxy/app/main.py` | FastAPI 라우트 (기존 수정 + 신규 추가) | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: db.py — SQLite 스키마 및 쿼리 헬퍼
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `travel-proxy/app/db.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: db.py 파일 생성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
DB_PATH = os.getenv("TRAVEL_DB_PATH", "/data/thumbs/travel.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _conn() -> sqlite3.Connection:
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
album TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mtime REAL NOT NULL,
|
||||||
|
has_thumb INTEGER DEFAULT 0,
|
||||||
|
indexed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
UNIQUE(album, filename)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album)")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS album_covers (
|
||||||
|
album TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def get_photos_by_region(albums: List[str], page: int, size: int) -> Dict[str, Any]:
|
||||||
|
"""region에 속한 앨범들의 사진을 페이지네이션하여 반환."""
|
||||||
|
if not albums:
|
||||||
|
return {"items": [], "total": 0, "has_next": False, "matched_albums": []}
|
||||||
|
|
||||||
|
placeholders = ",".join("?" for _ in albums)
|
||||||
|
|
||||||
|
with _conn() as conn:
|
||||||
|
# 앨범별 사진 수
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT album, COUNT(*) as cnt FROM photos WHERE album IN ({placeholders}) GROUP BY album",
|
||||||
|
albums,
|
||||||
|
).fetchall()
|
||||||
|
matched_albums = [{"album": r["album"], "count": r["cnt"]} for r in rows]
|
||||||
|
|
||||||
|
# 전체 수
|
||||||
|
total_row = conn.execute(
|
||||||
|
f"SELECT COUNT(*) as cnt FROM photos WHERE album IN ({placeholders})",
|
||||||
|
albums,
|
||||||
|
).fetchone()
|
||||||
|
total = total_row["cnt"]
|
||||||
|
|
||||||
|
# 페이지네이션
|
||||||
|
offset = (page - 1) * size
|
||||||
|
items = conn.execute(
|
||||||
|
f"""SELECT album, filename, mtime FROM photos
|
||||||
|
WHERE album IN ({placeholders})
|
||||||
|
ORDER BY album, filename
|
||||||
|
LIMIT ? OFFSET ?""",
|
||||||
|
[*albums, size, offset],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [dict(r) for r in items],
|
||||||
|
"total": total,
|
||||||
|
"has_next": (offset + size) < total,
|
||||||
|
"matched_albums": matched_albums,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_albums() -> List[Dict[str, Any]]:
|
||||||
|
"""전체 앨범 목록 + 사진 수 + 커버 정보."""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT p.album, COUNT(*) as count,
|
||||||
|
COALESCE(c.filename, MIN(p.filename)) as cover_filename
|
||||||
|
FROM photos p
|
||||||
|
LEFT JOIN album_covers c ON p.album = c.album
|
||||||
|
GROUP BY p.album
|
||||||
|
ORDER BY p.album
|
||||||
|
""").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def set_album_cover(album: str, filename: str) -> bool:
|
||||||
|
"""앨범 커버 지정. 해당 photo가 존재하면 True, 없으면 False."""
|
||||||
|
with _conn() as conn:
|
||||||
|
exists = conn.execute(
|
||||||
|
"SELECT 1 FROM photos WHERE album = ? AND filename = ?",
|
||||||
|
(album, filename),
|
||||||
|
).fetchone()
|
||||||
|
if not exists:
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO album_covers (album, filename, updated_at)
|
||||||
|
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
ON CONFLICT(album) DO UPDATE SET
|
||||||
|
filename = excluded.filename,
|
||||||
|
updated_at = excluded.updated_at""",
|
||||||
|
(album, filename),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_album_cover(album: str) -> Optional[str]:
|
||||||
|
"""앨범 커버 파일명 반환. 미지정 시 None."""
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT filename FROM album_covers WHERE album = ?",
|
||||||
|
(album,),
|
||||||
|
).fetchone()
|
||||||
|
return row["filename"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_photo(album: str, filename: str, mtime: float) -> str:
|
||||||
|
"""사진 upsert. 반환: 'added' | 'updated' | 'unchanged'."""
|
||||||
|
with _conn() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT mtime, has_thumb FROM photos WHERE album = ? AND filename = ?",
|
||||||
|
(album, filename),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO photos (album, filename, mtime, has_thumb) VALUES (?, ?, ?, 0)",
|
||||||
|
(album, filename, mtime),
|
||||||
|
)
|
||||||
|
return "added"
|
||||||
|
elif existing["mtime"] != mtime:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE photos SET mtime = ?, has_thumb = 0 WHERE album = ? AND filename = ?",
|
||||||
|
(mtime, album, filename),
|
||||||
|
)
|
||||||
|
return "updated"
|
||||||
|
return "unchanged"
|
||||||
|
|
||||||
|
|
||||||
|
def remove_missing_photos(album: str, existing_filenames: set) -> int:
|
||||||
|
"""폴더에 없는 사진을 DB에서 제거. 제거 수 반환."""
|
||||||
|
with _conn() as conn:
|
||||||
|
db_rows = conn.execute(
|
||||||
|
"SELECT filename FROM photos WHERE album = ?", (album,)
|
||||||
|
).fetchall()
|
||||||
|
db_filenames = {r["filename"] for r in db_rows}
|
||||||
|
to_remove = db_filenames - existing_filenames
|
||||||
|
|
||||||
|
if to_remove:
|
||||||
|
placeholders = ",".join("?" for _ in to_remove)
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM photos WHERE album = ? AND filename IN ({placeholders})",
|
||||||
|
[album, *to_remove],
|
||||||
|
)
|
||||||
|
# 삭제된 파일이 커버였으면 커버도 제거
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM album_covers WHERE album = ? AND filename IN ({placeholders})",
|
||||||
|
[album, *to_remove],
|
||||||
|
)
|
||||||
|
return len(to_remove)
|
||||||
|
|
||||||
|
|
||||||
|
def get_photos_without_thumb() -> List[Dict[str, str]]:
|
||||||
|
"""썸네일 미생성 사진 목록."""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT album, filename FROM photos WHERE has_thumb = 0"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def mark_thumb_done(album: str, filename: str) -> None:
|
||||||
|
"""썸네일 생성 완료 표시."""
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE photos SET has_thumb = 1 WHERE album = ? AND filename = ?",
|
||||||
|
(album, filename),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add travel-proxy/app/db.py
|
||||||
|
git commit -m "feat(travel-proxy): db.py — SQLite 스키마 + 쿼리 헬퍼"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: indexer.py — 폴더 동기화 + 썸네일 일괄 생성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `travel-proxy/app/indexer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: indexer.py 파일 생성**
|
||||||
|
|
||||||
|
기존 main.py의 `ensure_thumb` 로직(라인 105-144)과 `scan_album` 로직(라인 146-166)을 기반으로 작성. `IMAGE_EXT`, `THUMB_SIZE`, 경로 상수는 main.py에서 import.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Set
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
THUMB_SIZE = (480, 480)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_folder(folder: Path) -> List[Dict[str, Any]]:
|
||||||
|
"""폴더 내 이미지 파일 목록 수집 (os.scandir)."""
|
||||||
|
if not folder.exists():
|
||||||
|
return []
|
||||||
|
items = []
|
||||||
|
with os.scandir(folder) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
|
||||||
|
items.append({
|
||||||
|
"filename": entry.name,
|
||||||
|
"mtime": entry.stat().st_mtime,
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_thumb(src: Path, dest: Path) -> bool:
|
||||||
|
"""원본에서 480x480 썸네일 생성. 성공 시 True."""
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = dest.with_name(dest.stem + ".tmp" + dest.suffix)
|
||||||
|
try:
|
||||||
|
with Image.open(src) as im:
|
||||||
|
im.thumbnail(THUMB_SIZE)
|
||||||
|
ext = dest.suffix.lower()
|
||||||
|
if ext in (".jpg", ".jpeg"):
|
||||||
|
fmt = "JPEG"
|
||||||
|
elif ext == ".png":
|
||||||
|
fmt = "PNG"
|
||||||
|
elif ext == ".webp":
|
||||||
|
fmt = "WEBP"
|
||||||
|
else:
|
||||||
|
fmt = (im.format or "").upper() or "JPEG"
|
||||||
|
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||||
|
tmp.replace(dest)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Thumb generation failed: %s → %s", src, e)
|
||||||
|
try:
|
||||||
|
if tmp.exists():
|
||||||
|
tmp.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sync(
|
||||||
|
travel_root: Path,
|
||||||
|
thumb_root: Path,
|
||||||
|
region_map_path: Path,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
폴더 스캔 → DB 동기화 + 썸네일 일괄 생성.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"added": int, "removed": int, "thumbs_generated": int, "duration_sec": float}
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
# 1. region_map.json에서 전체 앨범 폴더 수집
|
||||||
|
with open(region_map_path, "r", encoding="utf-8") as f:
|
||||||
|
region_map = json.load(f)
|
||||||
|
|
||||||
|
all_albums: Set[str] = set()
|
||||||
|
for v in region_map.values():
|
||||||
|
if isinstance(v, list):
|
||||||
|
all_albums.update(v)
|
||||||
|
elif isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||||
|
all_albums.update(v["albums"])
|
||||||
|
|
||||||
|
# 2. 각 앨범 폴더 스캔 → DB 동기화
|
||||||
|
added = 0
|
||||||
|
removed = 0
|
||||||
|
|
||||||
|
for album in sorted(all_albums):
|
||||||
|
folder = travel_root / album
|
||||||
|
items = _scan_folder(folder)
|
||||||
|
existing_filenames = set()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
existing_filenames.add(item["filename"])
|
||||||
|
result = db.upsert_photo(album, item["filename"], item["mtime"])
|
||||||
|
if result == "added":
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
removed += db.remove_missing_photos(album, existing_filenames)
|
||||||
|
|
||||||
|
# 3. 썸네일 미생성 분 일괄 생성
|
||||||
|
no_thumb = db.get_photos_without_thumb()
|
||||||
|
thumbs_generated = 0
|
||||||
|
|
||||||
|
for photo in no_thumb:
|
||||||
|
src = travel_root / photo["album"] / photo["filename"]
|
||||||
|
dest = thumb_root / photo["album"] / photo["filename"]
|
||||||
|
if _generate_thumb(src, dest):
|
||||||
|
db.mark_thumb_done(photo["album"], photo["filename"])
|
||||||
|
thumbs_generated += 1
|
||||||
|
|
||||||
|
duration = round(time.time() - start, 2)
|
||||||
|
logger.info(
|
||||||
|
"Sync complete: added=%d removed=%d thumbs=%d duration=%.2fs",
|
||||||
|
added, removed, thumbs_generated, duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"added": added,
|
||||||
|
"removed": removed,
|
||||||
|
"thumbs_generated": thumbs_generated,
|
||||||
|
"duration_sec": duration,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add travel-proxy/app/indexer.py
|
||||||
|
git commit -m "feat(travel-proxy): indexer.py — 폴더 동기화 + 썸네일 일괄 생성"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: main.py 리팩토링 — DB 기반 photos API + 캐시 제거
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `travel-proxy/app/main.py`
|
||||||
|
|
||||||
|
이 Task에서 main.py의 메모리 캐시, `scan_album()`, 기존 `photos()` 라우트를 DB 기반으로 교체한다.
|
||||||
|
|
||||||
|
- [ ] **Step 1: main.py를 DB 기반으로 재작성**
|
||||||
|
|
||||||
|
main.py 전체를 아래로 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
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()
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# DB init
|
||||||
|
# -----------------------------
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Helpers
|
||||||
|
# -----------------------------
|
||||||
|
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 _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 _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)
|
||||||
|
tmp = out.with_name(out.stem + ".tmp" + out.suffix)
|
||||||
|
try:
|
||||||
|
with Image.open(src) as im:
|
||||||
|
im.thumbnail(THUMB_SIZE)
|
||||||
|
ext = out.suffix.lower()
|
||||||
|
if ext in (".jpg", ".jpeg"):
|
||||||
|
fmt = "JPEG"
|
||||||
|
elif ext == ".png":
|
||||||
|
fmt = "PNG"
|
||||||
|
elif ext == ".webp":
|
||||||
|
fmt = "WEBP"
|
||||||
|
else:
|
||||||
|
fmt = (im.format or "").upper() or "JPEG"
|
||||||
|
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||||
|
tmp.replace(out)
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if tmp.exists():
|
||||||
|
tmp.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Models
|
||||||
|
# -----------------------------
|
||||||
|
class CoverRequest(BaseModel):
|
||||||
|
filename: str
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Routes
|
||||||
|
# -----------------------------
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "healthy", "service": "travel-proxy"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/travel/regions")
|
||||||
|
def regions():
|
||||||
|
return load_regions_geojson()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/travel/photos")
|
||||||
|
def photos(
|
||||||
|
region: str = Query(...),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=100),
|
||||||
|
):
|
||||||
|
region_map = load_region_map()
|
||||||
|
albums = _get_albums_for_region(region, region_map)
|
||||||
|
result = get_photos_by_region(albums, page, size)
|
||||||
|
|
||||||
|
# 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": 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}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/media/travel/.thumb/{album}/{filename}")
|
||||||
|
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_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():
|
||||||
|
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add travel-proxy/app/main.py
|
||||||
|
git commit -m "refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 통합 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- 없음 (기존 파일 검증만)
|
||||||
|
|
||||||
|
- [ ] **Step 1: import 구조 확인**
|
||||||
|
|
||||||
|
travel-proxy/app/ 디렉토리에 `__init__.py`가 필요한지 확인. FastAPI uvicorn 실행 명령이 `app.main:app`이므로 패키지 import가 동작하려면 `__init__.py`가 필요.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls travel-proxy/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
`__init__.py`가 없으면 생성:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# travel-proxy/app/__init__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Dockerfile 확인**
|
||||||
|
|
||||||
|
현재 Dockerfile의 `COPY app /app/app` 라인이 db.py, indexer.py를 포함하는지 확인. 디렉토리 단위 복사이므로 추가 파일은 자동 포함됨. 변경 불필요.
|
||||||
|
|
||||||
|
- [ ] **Step 3: docker-compose.yml 환경변수 확인**
|
||||||
|
|
||||||
|
`TRAVEL_DB_PATH` 환경변수를 docker-compose.yml에 추가:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml의 travel-proxy 서비스 environment에 추가
|
||||||
|
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: photos 응답 호환성 검증**
|
||||||
|
|
||||||
|
기존 응답 필드와 비교:
|
||||||
|
- `region` ✓
|
||||||
|
- `page`, `size` ✓
|
||||||
|
- `total`, `has_next` ✓
|
||||||
|
- `items[].album`, `items[].file`, `items[].url`, `items[].thumb`, `items[].mtime` ✓
|
||||||
|
- `matched_albums` — 기존에는 `photos()` 응답에 없었으나 캐시 데이터에 포함. DB 버전은 항상 포함.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋 (변경 있을 시)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add travel-proxy/app/__init__.py docker-compose.yml
|
||||||
|
git commit -m "chore(travel-proxy): __init__.py + TRAVEL_DB_PATH 환경변수 추가"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: CLAUDE.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: travel-proxy 섹션에 DB 정보 추가**
|
||||||
|
|
||||||
|
CLAUDE.md의 travel-proxy 섹션에 아래 내용 추가:
|
||||||
|
|
||||||
|
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||||
|
- 파일 구조에 `db.py`, `indexer.py` 추가
|
||||||
|
|
||||||
|
API 목록 테이블에 신규 API 3개 추가:
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||||
|
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
|
||||||
|
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||||
|
|
||||||
|
`POST /api/travel/reload` 제거 표기.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: CLAUDE.md travel-proxy DB·API 업데이트"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user