From 1f41ee5352ba9bb6eadd1cea1d526ef1f8965406 Mon Sep 17 00:00:00 2001 From: drjones Date: Thu, 23 Jul 2026 04:22:31 -0700 Subject: [PATCH] Add support for multiple source folders - Server: S.folder (singular) -> S.folders (list). New /api/add_folder and /api/remove_folder endpoints; /api/scan and /api/choose_folder updated to work with the list (choose_folder now appends instead of replacing). Legacy single-folder config migrates transparently. - All folders feed one combined pending queue/journal/search, per the simpler of the two designs discussed. - organize_alias now resolves each photo's own source folder via find_owning_folder(), so category aliases land next to the original instead of in one designated folder -- verified live with two configured folders that the alias for each photo lands in its own folder and nothing leaks into the other. - Delete path-safety, thumbnail/photo serving, resolve_portable_path, undo_all, and sync-on-scan all extended to check/act across every configured folder instead of just one. - Frontend: folder list renders as removable chips; Add (typed path) and Browse (native picker, now appends) both wired to the new endpoints; Rescan All re-scans the current set without changing it. Co-Authored-By: Claude Sonnet 5 --- index.html | 78 ++++++++++++-- server.py | 309 +++++++++++++++++++++++++++++++++++------------------ 2 files changed, 270 insertions(+), 117 deletions(-) diff --git a/index.html b/index.html index 93b30b9..7dcf781 100644 --- a/index.html +++ b/index.html @@ -80,6 +80,16 @@ button:disabled{opacity:.3;cursor:not-allowed} font-family:var(--font-mono);font-size:12px;padding:9px 12px;border-radius:4px} .folderRow input:focus{outline:none;border-color:var(--cyan)} +.folderChips{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px} +.folderChips:empty{display:none} +.folderChip{display:flex;align-items:center;gap:8px;background:var(--panel); + border:1px solid var(--line);border-radius:14px;padding:5px 6px 5px 12px; + font-family:var(--font-mono);font-size:11px;color:var(--txt)} +.folderChip .path{max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.folderChip .rm{cursor:pointer;color:var(--dim);border:1px solid var(--line);border-radius:50%; + width:16px;height:16px;display:flex;align-items:center;justify-content:center;font-size:11px;line-height:1} +.folderChip .rm:hover{color:var(--red);border-color:var(--red)} + .grid{display:grid;grid-template-columns:330px 1fr;gap:14px} .grid-three{display:grid;grid-template-columns:330px 1fr 320px;gap:14px} @media(max-width:1200px){.grid,.grid-three{grid-template-columns:1fr}} @@ -338,12 +348,14 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
+
- - + + + - +
@@ -1008,6 +1020,51 @@ async function api(path, body){ return j; } +/* ---------- multi-folder source list ---------- */ +let currentFolders = []; + +function renderFolderChips(){ + const box = $("folderChips"); + if(!box) return; + box.innerHTML = ""; + currentFolders.forEach(f=>{ + const chip = document.createElement("div"); + chip.className = "folderChip"; + chip.innerHTML = `${escapeHtml(f)}×`; + chip.querySelector(".rm").onclick = ()=>removeFolder(f); + box.appendChild(chip); + }); +} + +function applyFolderList(folders){ + if(folders && folders.length){ + currentFolders = folders; + renderFolderChips(); + scanned = true; + setBadge("idle"); + } +} + +async function removeFolder(f){ + const j = await api("/api/remove_folder", {folder: f}); + if(j.ok) applyFolderList(j.folders); +} + +if($("btnAddFolderTyped")){ + $("btnAddFolderTyped").onclick = async ()=>{ + const val = $("folder").value.trim(); + if(!val) return; + $("btnAddFolderTyped").disabled = true; + const j = await api("/api/add_folder", {folder: val}); + $("btnAddFolderTyped").disabled = false; + if(j.ok){ + $("folder").value = ""; + showToast("Added folder: " + val, "ok"); + applyFolderList(j.folders); + } + }; +} + if($("btnBrowseFolder")){ $("btnBrowseFolder").onclick = async ()=>{ $("btnBrowseFolder").disabled = true; @@ -1015,10 +1072,8 @@ if($("btnBrowseFolder")){ const j = await api("/api/choose_folder", {}); $("btnBrowseFolder").disabled = false; if(j.ok && j.folder){ - $("folder").value = j.folder; - showToast("Selected folder: " + j.folder, "ok"); - scanned = true; - setBadge("idle"); + showToast("Added folder: " + j.folder, "ok"); + applyFolderList(j.folders); } else if($("fallbackFolderInput")){ $("fallbackFolderInput").click(); } @@ -1026,8 +1081,8 @@ if($("btnBrowseFolder")){ } $("btnScan").onclick = async ()=>{ - const j = await api("/api/scan",{folder:$("folder").value}); - if(j.images!=null){ scanned=true; setBadge("idle"); } + const j = await api("/api/scan", currentFolders.length ? {folders: currentFolders} : {}); + if(j.images!=null){ applyFolderList(j.folders); } }; $("btnStart").onclick = ()=>{ @@ -1061,7 +1116,7 @@ $("btnRetryFailed").onclick = async ()=>{ }; $("btnUndoAll").onclick = async ()=>{ - if(confirm("☣ WARNING ☣\nThis will remove all PHOTON embedded descriptions/categories/keywords from ALL files in the scanned folder. It will also clear organized symlinks, reset the database and delete the failures log.\n\nTHIS ACTION CANNOT BE UNDONE.\n\nDo you want to proceed?")){ + if(confirm("☣ WARNING ☣\nThis will remove all PHOTON embedded descriptions/categories/keywords from ALL files in every configured source folder ("+(currentFolders.length||"all")+"). It will also clear organized symlinks, reset the database and delete the failures log.\n\nTHIS ACTION CANNOT BE UNDONE.\n\nDo you want to proceed?")){ $("btnUndoAll").disabled = true; const j = await api("/api/undo_all", {}); $("btnUndoAll").disabled = false; @@ -2052,7 +2107,8 @@ function connect(){ CATS = st.categories||[]; renderCats(st.catCounts||{}); (st.log||[]).forEach(addLog); - if(st.folder) $("folder").value = st.folder; + currentFolders = st.folders || []; + renderFolderChips(); applyStats(st); setBadge(st.status||"idle"); if(st.total>0) scanned=true, setBadge(st.status||"idle"); diff --git a/server.py b/server.py index d7368f9..22b04e8 100644 --- a/server.py +++ b/server.py @@ -32,47 +32,55 @@ PORT = int(os.environ.get("PHOTON_PORT", 8765)) FOLDER_CONFIG_FILE = os.path.join(APP_DIR, "photon_folder.json") -def load_saved_folder(): +def load_saved_folders(): + """Returns the list of configured source folders, migrating transparently + from the old single-folder config format if that's all that's on disk.""" if os.path.exists(FOLDER_CONFIG_FILE): try: with open(FOLDER_CONFIG_FILE, "r", encoding="utf-8") as f: - folder = json.load(f).get("folder") - if folder and os.path.isdir(folder): - return folder + data = json.load(f) + folders = data.get("folders") + if folders is None and data.get("folder"): + folders = [data["folder"]] + if folders: + existing = [f for f in folders if os.path.isdir(f)] + if existing: + return existing except Exception: pass - return None + return [] -def save_folder_config(folder): +def save_folders_config(folders): try: with open(FOLDER_CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump({"folder": folder}, f) + json.dump({"folders": folders}, f) except Exception: pass -def resolve_default_folder(): +def resolve_default_folders(): """Zero-config folder detection, in priority order: 1. `python3 server.py /path/to/photos` — explicit CLI argument 2. PHOTON_FOLDER env var - 3. the last folder scanned in a previous session (remembered automatically) + 3. the folders configured in a previous session (remembered automatically) 4. the parent directory of wherever this app folder lives — the intended workflow is dropping the PHOTON folder directly inside a photo collection, so its parent IS that collection. - Always overridable from the Console tab's folder field + Scan button. + Always overridable from the Console tab's folder list. """ if len(sys.argv) > 1: arg = os.path.abspath(sys.argv[1]) if os.path.isdir(arg): - return arg + return [arg] env = os.environ.get("PHOTON_FOLDER") if env and os.path.isdir(env): - return os.path.abspath(env) - saved = load_saved_folder() + return [os.path.abspath(env)] + saved = load_saved_folders() if saved: return saved - return os.path.dirname(APP_DIR) + return [os.path.dirname(APP_DIR)] -DEFAULT_FOLDER = resolve_default_folder() +DEFAULT_FOLDERS = resolve_default_folders() +DEFAULT_FOLDER = DEFAULT_FOLDERS[0] JOURNAL = os.path.join(APP_DIR, "photon_journal.jsonl") CATEGORIES_FILE = os.path.join(APP_DIR, "photon_categories.json") @@ -166,7 +174,7 @@ class State: def __init__(self): self.lock = threading.RLock() self.status = "idle" # idle | scanning | running | paused | stopping | done - self.folder = DEFAULT_FOLDER + self.folders = list(DEFAULT_FOLDERS) # every configured source folder self.files = [] # pending image paths (after scan) self.video_files = [] # video paths found on last scan self.total_images = 0 @@ -220,13 +228,43 @@ def resolve_portable_path(path): if os.path.exists(path): return path with S.lock: - curr_folder = S.folder - if curr_folder and os.path.isdir(curr_folder): - cand = os.path.join(curr_folder, os.path.basename(path)) - if os.path.exists(cand): - return cand + folders = list(S.folders) + for folder in folders: + if folder and os.path.isdir(folder): + cand = os.path.join(folder, os.path.basename(path)) + if os.path.exists(cand): + return cand return path +def is_inside_any_folder(abs_path, folders=None): + if folders is None: + with S.lock: + folders = list(S.folders) + for folder in folders: + try: + f = os.path.abspath(folder) + if os.path.commonpath([f, abs_path]) == f: + return True + except ValueError: + continue + return False + +def find_owning_folder(path): + """Which configured source folder is this file physically under? Used so + a photo's _organized alias lands next to its own original, even when + several source folders (possibly on different drives) are configured.""" + abs_path = os.path.abspath(path) + with S.lock: + folders = list(S.folders) + for folder in folders: + try: + f = os.path.abspath(folder) + if os.path.commonpath([f, abs_path]) == f: + return f + except ValueError: + continue + return folders[0] if folders else DEFAULT_FOLDER + def read_journal_deduped(): """Read the journal into {abs_path: latest_record}. journal_write only ever appends (fast during a tagging run), so a photo retagged more than @@ -300,25 +338,27 @@ def trash_or_remove_file(path): def cleanup_organized_symlinks(abs_paths): """Remove _organized alias symlinks pointing at any of the given files. - One folder walk covers the whole batch instead of one walk per file.""" + One folder walk per configured folder covers the whole batch, instead + of one walk per file.""" if not abs_paths: return with S.lock: - folder = S.folder - org_root = os.path.join(folder, "_organized") - if not os.path.exists(org_root): - return + folders = list(S.folders) targets = set(abs_paths) - for root, dirs, files in os.walk(org_root): - dirs[:] = [d for d in dirs if not d.startswith(".")] - for name in files: - p = os.path.join(root, name) - if os.path.islink(p): - try: - if os.path.realpath(p) in targets: - os.remove(p) - except Exception: - pass + for folder in folders: + org_root = os.path.join(folder, "_organized") + if not os.path.exists(org_root): + continue + for root, dirs, files in os.walk(org_root): + dirs[:] = [d for d in dirs if not d.startswith(".")] + for name in files: + p = os.path.join(root, name) + if os.path.islink(p): + try: + if os.path.realpath(p) in targets: + os.remove(p) + except Exception: + pass def cleanup_thumb_cache(abs_paths): for abs_path in abs_paths: @@ -394,14 +434,8 @@ def delete_photo_file_and_record(path, skip_records=False): if not path: return False, "path is required" abs_path = os.path.abspath(path) - with S.lock: - folder = os.path.abspath(S.folder) if S.folder else None - try: - inside_folder = bool(folder) and os.path.commonpath([folder, abs_path]) == folder - except ValueError: - inside_folder = False - if not inside_folder: - return False, f"refusing to delete outside the configured photo folder: {abs_path}" + if not is_inside_any_folder(abs_path): + return False, f"refusing to delete outside the configured photo folders: {abs_path}" deleted_disk = False if os.path.exists(abs_path): deleted_disk = trash_or_remove_file(abs_path) @@ -743,6 +777,35 @@ def scan_folder(folder): videos.append(os.path.join(root, name)) return images, videos, sidecars +def rescan_configured_folders(): + """Scan every folder in S.folders and merge the results into the + pending-work state, deduping any path that shows up under more than + one configured folder (e.g. one nested inside another).""" + with S.lock: + folders = list(S.folders) + all_images, all_videos, total_sidecars = [], [], 0 + seen = set() + for folder in folders: + images, videos, sidecars = scan_folder(folder) + for p in images: + if p not in seen: + seen.add(p) + all_images.append(p) + for p in videos: + if p not in seen: + seen.add(p) + all_videos.append(p) + total_sidecars += sidecars + done = sum(1 for p in all_images if p in S.done_paths) + with S.lock: + S.files = all_images + S.total_images = len(all_images) + S.video_files = all_videos + S.skipped_videos = len(all_videos) + S.skipped_sidecars = total_sidecars + S.status = "idle" + return all_images, all_videos, total_sidecars, done + def organize_alias(folder, file_path, category): folder = os.path.abspath(folder) file_path = os.path.abspath(file_path) @@ -1046,8 +1109,6 @@ def process_loop(settings): ocr_model = settings.get("ocrModel") or "glm-ocr:latest" organize = bool(settings.get("organize", False)) integrity = bool(settings.get("integrity", False)) - with S.lock: - organize_folder = S.folder or DEFAULT_FOLDER photo_prompt = build_prompt(length_key, "photo") shot_prompt = build_prompt(length_key, "screenshot") @@ -1240,7 +1301,7 @@ def process_loop(settings): else: write_metadata(path, desc, category, keep_backup, preserve_date) if organize: - organize_alias(organize_folder, path, category) + organize_alias(find_owning_folder(path), path, category) dt = time.time() - t0 with S.lock: @@ -1363,7 +1424,9 @@ class Handler(BaseHTTPRequestHandler): elif self.path == "/api/state": with S.lock: payload = stats_payload() - payload.update({"folder": S.folder, "log": S.log_ring[-200:], + payload.update({"folders": S.folders, + "folder": S.folders[0] if S.folders else "", + "log": S.log_ring[-200:], "categories": CATEGORIES, "current": S.current, "settings": S.settings, "failuresCount": len(S.failed_paths)}) self._json(payload) @@ -1386,7 +1449,7 @@ class Handler(BaseHTTPRequestHandler): self.send_response(404); self.end_headers(); return abs_path = os.path.abspath(img_path) with S.lock: - folder_ok = abs_path.startswith(os.path.abspath(S.folder)) + folder_ok = is_inside_any_folder(abs_path, S.folders) journal_ok = abs_path in S.done_paths if not (folder_ok or journal_ok): self.send_response(403); self.end_headers(); return @@ -1423,7 +1486,7 @@ class Handler(BaseHTTPRequestHandler): self.send_response(404); self.end_headers(); return abs_path = os.path.abspath(img_path) with S.lock: - folder_ok = abs_path.startswith(os.path.abspath(S.folder)) + folder_ok = is_inside_any_folder(abs_path, S.folders) journal_ok = abs_path in S.done_paths if not (folder_ok or journal_ok): self.send_response(403); self.end_headers(); return @@ -1594,8 +1657,9 @@ class Handler(BaseHTTPRequestHandler): re_read = params.get("re_read", ["0"])[0] == "1" if re_read: with S.lock: - folder = S.folder - sync_metadata_from_folder(folder) + folders = list(S.folders) + for folder in folders: + sync_metadata_from_folder(folder) push_stats() filtered = filtered_search_results(q, cat, date_from, date_to, sort, ftype) @@ -1829,55 +1893,90 @@ class Handler(BaseHTTPRequestHandler): self._json({"error": "bad json"}, 400); return if self.path == "/api/scan": - folder = body.get("folder") or DEFAULT_FOLDER - if not os.path.isdir(folder): - self._json({"error": f"not a folder: {folder}"}, 400); return + # {"folders": [...]} replaces the whole list; {"folder": "..."} + # (legacy single-folder form) replaces it with just that one. + requested = body.get("folders") + if not requested: + single = body.get("folder") or DEFAULT_FOLDER + requested = [single] + valid = [os.path.abspath(f) for f in requested if f and os.path.isdir(f)] + if not valid: + self._json({"error": f"no valid folder in: {requested}"}, 400); return with S.lock: if S.status == "running": self._json({"error": "stop the run before rescanning"}, 400); return - S.status = "scanning"; S.folder = folder - save_folder_config(folder) + S.status = "scanning"; S.folders = valid + save_folders_config(valid) broadcast("state", {"status": "scanning"}) - log("info", f"scanning {folder} …") - images, videos, sidecars = scan_folder(folder) - done = sum(1 for p in images if p in S.done_paths) - with S.lock: - S.files = images - S.total_images = len(images) - S.video_files = videos - S.skipped_videos = len(videos) - S.skipped_sidecars = sidecars - S.status = "idle" + log("info", f"scanning {len(valid)} folder(s): {', '.join(valid)} …") + images, videos, sidecars, done = rescan_configured_folders() log("ok", f"scan complete: {len(images)} images | {len(videos)} videos found | " f"{sidecars} hidden/sidecar files ignored | {done} already tagged") broadcast("state", {"status": "idle"}) push_stats() - self._json({"images": len(images), "videos": len(videos), + self._json({"folders": valid, "images": len(images), "videos": len(videos), "sidecars": sidecars, "alreadyDone": done}) + elif self.path == "/api/add_folder": + folder = body.get("folder") + if not folder or not os.path.isdir(folder): + self._json({"error": f"not a folder: {folder}"}, 400); return + with S.lock: + if S.status == "running": + self._json({"error": "stop the run before adding a folder"}, 400); return + folder = os.path.abspath(folder) + with S.lock: + already = any(os.path.abspath(f) == folder for f in S.folders) + if not already: + S.folders.append(folder) + folders = list(S.folders) + save_folders_config(folders) + log("info", f"{'already tracking' if already else 'added'} folder: {folder} — rescanning all …") + images, videos, sidecars, done = rescan_configured_folders() + sync_metadata_from_folder(folder) + load_journal() + push_stats() + self._json({"ok": True, "folders": folders, "images": len(images), + "videos": len(videos), "sidecars": sidecars, "alreadyDone": done}) + + elif self.path == "/api/remove_folder": + folder = body.get("folder") + if not folder: + self._json({"error": "folder is required"}, 400); return + with S.lock: + if S.status == "running": + self._json({"error": "stop the run before removing a folder"}, 400); return + folder = os.path.abspath(folder) + with S.lock: + S.folders = [f for f in S.folders if os.path.abspath(f) != folder] + folders = list(S.folders) + save_folders_config(folders) + log("info", f"removed folder: {folder} — rescanning remaining {len(folders)} folder(s) …") + images, videos, sidecars, done = rescan_configured_folders() + push_stats() + self._json({"ok": True, "folders": folders, "images": len(images), + "videos": len(videos), "sidecars": sidecars, "alreadyDone": done}) + elif self.path == "/api/choose_folder": try: - cmd = ["osascript", "-e", 'tell application "System Events" to set frontmost of (first process whose background only is false) to true', "-e", 'POSIX path of (choose folder with prompt "Select Photo Folder to Organize:")'] + cmd = ["osascript", "-e", 'tell application "System Events" to set frontmost of (first process whose background only is false) to true', "-e", 'POSIX path of (choose folder with prompt "Add a photo folder:")'] r = subprocess.run(cmd, capture_output=True, timeout=60) if r.returncode == 0: chosen = r.stdout.decode("utf-8").strip() if chosen and os.path.isdir(chosen): + chosen = os.path.abspath(chosen) with S.lock: - S.folder = chosen - save_folder_config(chosen) - images, videos, sidecars = scan_folder(chosen) - done = sum(1 for p in images if p in S.done_paths) - with S.lock: - S.files = images - S.total_images = len(images) - S.video_files = videos - S.skipped_videos = len(videos) - S.skipped_sidecars = sidecars - S.status = "idle" + already = any(os.path.abspath(f) == chosen for f in S.folders) + if not already: + S.folders.append(chosen) + folders = list(S.folders) + save_folders_config(folders) + images, videos, sidecars, done = rescan_configured_folders() sync_metadata_from_folder(chosen) load_journal() push_stats() - self._json({"ok": True, "folder": chosen, "images": len(images), "videos": len(videos), "alreadyDone": done}) + self._json({"ok": True, "folder": chosen, "folders": folders, + "images": len(images), "videos": len(videos), "alreadyDone": done}) return self._json({"error": "No folder selected or dialog cancelled"}, 400) except Exception as e: @@ -1930,25 +2029,26 @@ class Handler(BaseHTTPRequestHandler): elif self.path == "/api/undo_all": with S.lock: - folder = S.folder - log("warn", f"UNDO ALL: Removing all PHOTON metadata from files in {folder} ...") - args = ["exiftool", "-r", "-P", "-overwrite_original", "-if", - "$Subject =~ /photon-tagged/ or $Keywords =~ /photon-tagged/", - "-EXIF:ImageDescription=", "-IPTC:Caption-Abstract=", "-XMP-dc:Description=", "-Description=", - "-XMP-dc:Subject-=photon-tagged", "-Keywords-=photon-tagged"] - for cat in CATEGORIES: - args.append(f"-XMP-dc:Subject-={cat}") - args.append(f"-Keywords-={cat}") - args.append(folder) - r = subprocess.run(args, capture_output=True, timeout=600) - log("info", f"Exiftool undo completed: {r.stdout.decode(errors='replace')[:200]}") - org_root = os.path.join(folder, "_organized") - if os.path.exists(org_root): - try: - shutil.rmtree(org_root) - log("info", "Deleted organized symlink directory.") - except Exception as e: - log("error", f"Failed to delete _organized folder: {e}") + folders = list(S.folders) + log("warn", f"UNDO ALL: Removing all PHOTON metadata from files in {len(folders)} folder(s): {', '.join(folders)} ...") + for folder in folders: + args = ["exiftool", "-r", "-P", "-overwrite_original", "-if", + "$Subject =~ /photon-tagged/ or $Keywords =~ /photon-tagged/", + "-EXIF:ImageDescription=", "-IPTC:Caption-Abstract=", "-XMP-dc:Description=", "-Description=", + "-XMP-dc:Subject-=photon-tagged", "-Keywords-=photon-tagged"] + for cat in CATEGORIES: + args.append(f"-XMP-dc:Subject-={cat}") + args.append(f"-Keywords-={cat}") + args.append(folder) + r = subprocess.run(args, capture_output=True, timeout=600) + log("info", f"Exiftool undo completed for {folder}: {r.stdout.decode(errors='replace')[:200]}") + org_root = os.path.join(folder, "_organized") + if os.path.exists(org_root): + try: + shutil.rmtree(org_root) + log("info", f"Deleted organized symlink directory in {folder}.") + except Exception as e: + log("error", f"Failed to delete _organized folder in {folder}: {e}") if os.path.exists(JOURNAL): try: os.remove(JOURNAL) @@ -2006,10 +2106,9 @@ class Handler(BaseHTTPRequestHandler): keep_backup = bool(S.settings.get("keepBackup", False)) preserve_date = bool(S.settings.get("preserveDate", True)) organize = bool(S.settings.get("organize", False)) - folder = S.folder write_metadata(path, desc, category, keep_backup, preserve_date) if organize: - organize_alias(folder, path, category) + organize_alias(find_owning_folder(path), path, category) existing_recs = [] found = False if os.path.exists(JOURNAL): @@ -2116,9 +2215,7 @@ class Handler(BaseHTTPRequestHandler): pass write_metadata(path, desc, category, keep_backup, preserve_date) if organize: - with S.lock: - folder = S.folder - organize_alias(folder, path, category) + organize_alias(find_owning_folder(path), path, category) existing_recs = [] found = False if os.path.exists(JOURNAL): @@ -2270,7 +2367,7 @@ def main(): load_failures() load_phash_cache() print(f"PHOTON console → http://localhost:{PORT}") - print(f"photo folder → {DEFAULT_FOLDER}" + print(f"photo folder(s) → {', '.join(DEFAULT_FOLDERS)}" + (" (auto-detected — change it anytime from the Console tab)" if len(sys.argv) <= 1 and not os.environ.get("PHOTON_FOLDER") else "")) if n: print(f"journal loaded: {n} photos already tagged (will be skipped on resume)")