feat(travel-proxy): 신규 폴더 자동 탐색 + region_map 오버라이드 분리
- indexer: travel_root 전체 서브디렉토리 스캔하여 region_map에 없는 폴더도 자동 인덱싱 - RO 원본 대신 RW thumb_root에 region_map_extra.json으로 오버라이드 저장 - regions API: 미분류 지역 동적 추가 - sync 응답에 discovered 필드 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,64 @@ def _generate_thumb(src: Path, dest: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _load_region_map_merged(region_map_path: Path, thumb_root: Path) -> dict:
|
||||||
|
"""
|
||||||
|
원본 region_map.json + RW 오버라이드 파일 병합.
|
||||||
|
원본은 RO 마운트이므로 신규 폴더는 thumb_root/region_map_extra.json에 저장.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(region_map_path, "r", encoding="utf-8") as f:
|
||||||
|
region_map = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||||
|
logger.error("Failed to load region_map: %s", e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 오버라이드 파일 병합
|
||||||
|
extra_path = thumb_root / "region_map_extra.json"
|
||||||
|
if extra_path.exists():
|
||||||
|
try:
|
||||||
|
with open(extra_path, "r", encoding="utf-8") as f:
|
||||||
|
extra = json.load(f)
|
||||||
|
for k, v in extra.items():
|
||||||
|
if k in region_map:
|
||||||
|
# 기존 지역에 앨범 추가 (중복 방지)
|
||||||
|
existing = region_map[k]
|
||||||
|
if isinstance(existing, list) and isinstance(v, list):
|
||||||
|
merged = list(dict.fromkeys(existing + v))
|
||||||
|
region_map[k] = merged
|
||||||
|
else:
|
||||||
|
region_map[k] = v
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.warning("Failed to load region_map_extra: %s", e)
|
||||||
|
|
||||||
|
return region_map
|
||||||
|
|
||||||
|
|
||||||
|
def _save_extra_albums(thumb_root: Path, new_folders: List[str]) -> None:
|
||||||
|
"""신규 발견 폴더를 RW 영역의 오버라이드 파일에 저장."""
|
||||||
|
extra_path = thumb_root / "region_map_extra.json"
|
||||||
|
extra = {}
|
||||||
|
if extra_path.exists():
|
||||||
|
try:
|
||||||
|
with open(extra_path, "r", encoding="utf-8") as f:
|
||||||
|
extra = json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
extra = {}
|
||||||
|
|
||||||
|
uncategorized = extra.get("미분류", [])
|
||||||
|
existing = set(uncategorized)
|
||||||
|
added = [f for f in new_folders if f not in existing]
|
||||||
|
if added:
|
||||||
|
uncategorized.extend(added)
|
||||||
|
extra["미분류"] = uncategorized
|
||||||
|
try:
|
||||||
|
with open(extra_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(extra, f, ensure_ascii=False, indent=2)
|
||||||
|
logger.info("Saved %d new folders to region_map_extra.json → 미분류", len(added))
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("Cannot write region_map_extra: %s", e)
|
||||||
|
|
||||||
|
|
||||||
def sync(
|
def sync(
|
||||||
travel_root: Path,
|
travel_root: Path,
|
||||||
thumb_root: Path,
|
thumb_root: Path,
|
||||||
@@ -73,17 +131,13 @@ def sync(
|
|||||||
폴더 스캔 → DB 동기화 + 썸네일 일괄 생성.
|
폴더 스캔 → DB 동기화 + 썸네일 일괄 생성.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{"added": int, "removed": int, "thumbs_generated": int, "duration_sec": float}
|
{"added": int, "removed": int, "thumbs_generated": int,
|
||||||
|
"discovered": int, "duration_sec": float}
|
||||||
"""
|
"""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|
||||||
# 1. region_map.json에서 전체 앨범 폴더 수집
|
# 1. region_map.json + 오버라이드 병합
|
||||||
try:
|
region_map = _load_region_map_merged(region_map_path, thumb_root)
|
||||||
with open(region_map_path, "r", encoding="utf-8") as f:
|
|
||||||
region_map = json.load(f)
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
|
||||||
logger.error("Failed to load region_map: %s", e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
all_albums: Set[str] = set()
|
all_albums: Set[str] = set()
|
||||||
for v in region_map.values():
|
for v in region_map.values():
|
||||||
@@ -92,6 +146,35 @@ def sync(
|
|||||||
elif isinstance(v, dict) and isinstance(v.get("albums"), list):
|
elif isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||||
all_albums.update(v["albums"])
|
all_albums.update(v["albums"])
|
||||||
|
|
||||||
|
# 1-b. 파일시스템에서 신규 폴더 자동 탐색
|
||||||
|
discovered = 0
|
||||||
|
new_folders = []
|
||||||
|
try:
|
||||||
|
with os.scandir(travel_root) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.is_dir() or entry.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
if entry.name in all_albums:
|
||||||
|
continue
|
||||||
|
# 이미지가 하나라도 있는 폴더만 추가
|
||||||
|
folder = travel_root / entry.name
|
||||||
|
has_images = any(
|
||||||
|
Path(f.name).suffix.lower() in IMAGE_EXT
|
||||||
|
for f in os.scandir(folder)
|
||||||
|
if f.is_file()
|
||||||
|
)
|
||||||
|
if has_images:
|
||||||
|
all_albums.add(entry.name)
|
||||||
|
new_folders.append(entry.name)
|
||||||
|
discovered += 1
|
||||||
|
logger.info("Discovered new album folder: %s", entry.name)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("Failed to scan travel_root for new folders: %s", e)
|
||||||
|
|
||||||
|
# 신규 폴더를 RW 오버라이드 파일에 영구 저장
|
||||||
|
if new_folders:
|
||||||
|
_save_extra_albums(thumb_root, new_folders)
|
||||||
|
|
||||||
# 2. 각 앨범 폴더 스캔 → DB 배치 동기화
|
# 2. 각 앨범 폴더 스캔 → DB 배치 동기화
|
||||||
added = 0
|
added = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
@@ -123,8 +206,8 @@ def sync(
|
|||||||
|
|
||||||
duration = round(time.time() - start, 2)
|
duration = round(time.time() - start, 2)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Sync complete: added=%d updated=%d removed=%d thumbs=%d duration=%.2fs",
|
"Sync complete: added=%d updated=%d removed=%d thumbs=%d discovered=%d duration=%.2fs",
|
||||||
added, updated, removed, thumbs_generated, duration,
|
added, updated, removed, thumbs_generated, discovered, duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -132,5 +215,6 @@ def sync(
|
|||||||
"updated": updated,
|
"updated": updated,
|
||||||
"removed": removed,
|
"removed": removed,
|
||||||
"thumbs_generated": thumbs_generated,
|
"thumbs_generated": thumbs_generated,
|
||||||
|
"discovered": discovered,
|
||||||
"duration_sec": duration,
|
"duration_sec": duration,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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, mark_thumb_done
|
from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, mark_thumb_done
|
||||||
from .indexer import sync
|
from .indexer import sync, _load_region_map_merged
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ def _read_json(path: Path) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def load_region_map() -> dict:
|
def load_region_map() -> dict:
|
||||||
return _read_json(REGION_MAP_PATH)
|
return _load_region_map_merged(REGION_MAP_PATH, THUMB_ROOT)
|
||||||
|
|
||||||
|
|
||||||
def load_regions_geojson() -> dict:
|
def load_regions_geojson() -> dict:
|
||||||
@@ -115,7 +115,20 @@ def health():
|
|||||||
|
|
||||||
@app.get("/api/travel/regions")
|
@app.get("/api/travel/regions")
|
||||||
def regions():
|
def regions():
|
||||||
return load_regions_geojson()
|
geojson = load_regions_geojson()
|
||||||
|
# 미분류 지역이 region_map에 있으면 GeoJSON에도 동적 추가
|
||||||
|
region_map = load_region_map()
|
||||||
|
existing_ids = {
|
||||||
|
f.get("properties", {}).get("id")
|
||||||
|
for f in geojson.get("features", [])
|
||||||
|
}
|
||||||
|
if "미분류" in region_map and "미분류" not in existing_ids:
|
||||||
|
geojson.setdefault("features", []).append({
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": {"id": "미분류", "name": "미분류"},
|
||||||
|
"geometry": None,
|
||||||
|
})
|
||||||
|
return geojson
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/travel/photos")
|
@app.get("/api/travel/photos")
|
||||||
|
|||||||
Reference in New Issue
Block a user