-
-
+
+
+
-
+
@@ -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)")