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: try: mtime = entry.stat().st_mtime except OSError as e: logger.warning("Cannot stat %s: %s", entry.path, e) continue items.append({ "filename": entry.name, "mtime": 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 _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 = _load_extra(thumb_root) if extra: # _removes: 원본에서 제거할 항목 적용 removes = extra.get("_removes", {}) for region_id, remove_albums in removes.items(): if region_id in region_map: existing = region_map[region_id] if isinstance(existing, list): region_map[region_id] = [a for a in existing if a not in remove_albums] elif isinstance(existing, dict) and "albums" in existing: existing["albums"] = [a for a in existing["albums"] if a not in remove_albums] # 나머지 키 병합 (_removes, _regions_meta 제외) for k, v in extra.items(): if k.startswith("_"): continue 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 return region_map def _extra_path(thumb_root: Path) -> Path: return thumb_root / "region_map_extra.json" def _load_extra(thumb_root: Path) -> dict: """RW 오버라이드 파일 로드.""" path = _extra_path(thumb_root) if path.exists(): try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError): pass return {} def _save_extra(thumb_root: Path, data: dict) -> None: """RW 오버라이드 파일 저장.""" path = _extra_path(thumb_root) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def _save_extra_albums(thumb_root: Path, new_folders: List[str]) -> None: """신규 발견 폴더를 RW 영역의 오버라이드 파일에 저장.""" extra = _load_extra(thumb_root) uncategorized = extra.get("미분류", []) if isinstance(uncategorized, dict): uncategorized = uncategorized.get("albums", []) existing = set(uncategorized) added = [f for f in new_folders if f not in existing] if added: uncategorized.extend(added) extra["미분류"] = uncategorized try: _save_extra(thumb_root, extra) 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 move_album_region( thumb_root: Path, region_map_path: Path, album: str, new_region: str, ) -> dict: """ 앨범의 지역을 변경. extra 파일에서 이전 지역에서 제거 + 새 지역에 추가. 원본 region_map.json의 매핑을 오버라이드 하기 위해 extra에 _remove 키로 원본에서 제거할 항목을 기록. Returns: {"album": str, "old_region": str, "new_region": str} """ # 현재 병합 맵에서 앨범의 기존 지역 조회 merged = _load_region_map_merged(region_map_path, thumb_root) old_region = None for region_id, v in merged.items(): album_list = v if isinstance(v, list) else v.get("albums", []) if album in album_list: old_region = region_id break extra = _load_extra(thumb_root) # 1. 기존 지역에서 제거 if old_region: # extra에 해당 지역의 앨범 목록이 있으면 거기서 제거 if old_region in extra: old_list = extra[old_region] if isinstance(old_list, dict): old_list = old_list.get("albums", []) if album in old_list: old_list.remove(album) extra[old_region] = old_list # 원본 region_map에 있는 경우 _overrides로 제거 기록 try: with open(region_map_path, "r", encoding="utf-8") as f: original = json.load(f) except (FileNotFoundError, json.JSONDecodeError): original = {} orig_list = original.get(old_region, []) if isinstance(orig_list, dict): orig_list = orig_list.get("albums", []) if album in orig_list: removes = extra.setdefault("_removes", {}) remove_list = removes.setdefault(old_region, []) if album not in remove_list: remove_list.append(album) # 2. 새 지역에 추가 new_list = extra.get(new_region, []) if isinstance(new_list, dict): new_list = new_list.get("albums", []) if album not in new_list: new_list.append(album) extra[new_region] = new_list # _removes에서 새 지역의 해당 앨범은 제거 (되돌리기 대응) if "_removes" in extra and new_region in extra["_removes"]: if album in extra["_removes"][new_region]: extra["_removes"][new_region].remove(album) _save_extra(thumb_root, extra) logger.info("Moved album '%s': %s → %s", album, old_region or "없음", new_region) return {"album": album, "old_region": old_region or "미분류", "new_region": new_region} def sync( travel_root: Path, thumb_root: Path, region_map_path: Path, ) -> Dict[str, Any]: """ 폴더 스캔 → DB 동기화 + 썸네일 일괄 생성. Returns: {"added": int, "removed": int, "thumbs_generated": int, "discovered": int, "duration_sec": float} """ start = time.time() # 1. region_map.json + 오버라이드 병합 region_map = _load_region_map_merged(region_map_path, thumb_root) 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"]) # 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 배치 동기화 added = 0 updated = 0 removed = 0 sorted_albums = sorted(all_albums) total_albums = len(sorted_albums) logger.info("Syncing %d albums...", total_albums) for i, album in enumerate(sorted_albums, 1): folder = travel_root / album items = _scan_folder(folder) existing_filenames = {item["filename"] for item in items} result = db.batch_sync_album(album, items, existing_filenames) added += result["added"] updated += result["updated"] removed += result["removed"] if result["added"] or result["removed"]: logger.info( "[%d/%d] %s: +%d/-%d (%d files)", i, total_albums, album, result["added"], result["removed"], len(items), ) logger.info("DB sync done: added=%d updated=%d removed=%d", added, updated, removed) # 3. 썸네일 미생성 분 일괄 생성 no_thumb = db.get_photos_without_thumb() thumbs_generated = 0 thumb_done_batch = [] if no_thumb: logger.info("Generating %d thumbnails...", len(no_thumb)) for idx, photo in enumerate(no_thumb, 1): src = travel_root / photo["album"] / photo["filename"] dest = thumb_root / photo["album"] / photo["filename"] if _generate_thumb(src, dest): thumb_done_batch.append(photo) thumbs_generated += 1 if idx % 100 == 0: logger.info("Thumbnails: %d/%d done", idx, len(no_thumb)) db.batch_mark_thumbs_done(thumb_done_batch) duration = round(time.time() - start, 2) logger.info( "Sync complete: added=%d updated=%d removed=%d thumbs=%d discovered=%d duration=%.2fs", added, updated, removed, thumbs_generated, discovered, duration, ) return { "added": added, "updated": updated, "removed": removed, "thumbs_generated": thumbs_generated, "discovered": discovered, "duration_sec": duration, }