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 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-07-23 04:22:31 -07:00
parent 00e86dc758
commit 1f41ee5352
2 changed files with 270 additions and 117 deletions

View File

@@ -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}
<!-- ==================== CONSOLE TAB ==================== -->
<div id="tab-console" class="tab-content active">
<div id="folderChips" class="folderChips"></div>
<div class="controls">
<div class="folderRow">
<input id="folder" spellcheck="false" placeholder="/path/to/your/photos">
<button id="btnBrowseFolder" title="Open native macOS folder chooser">📁 Browse</button>
<input id="folder" spellcheck="false" placeholder="/path/to/your/photos — add as many source folders as you like">
<button id="btnAddFolderTyped" title="Add this typed path as a source folder">+ Add</button>
<button id="btnBrowseFolder" title="Open native macOS folder chooser to add a source folder">📁 Browse</button>
<input type="file" id="fallbackFolderInput" webkitdirectory style="display:none">
<button id="btnScan">◈ Scan Directory</button>
<button id="btnScan" title="Re-scan every configured source folder now">⟳ Rescan All</button>
</div>
<button id="btnStart" class="btn-primary" disabled>▶ Start Pipeline</button>
<button id="btnPause" disabled>❚❚ Pause</button>
@@ -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 = `<span class="path" title="${escapeHtml(f)}">${escapeHtml(f)}</span><span class="rm" title="Remove this folder">×</span>`;
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");

253
server.py
View File

@@ -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))
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,15 +338,17 @@ 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
folders = list(S.folders)
targets = set(abs_paths)
for folder in folders:
org_root = os.path.join(folder, "_organized")
if not os.path.exists(org_root):
return
targets = set(abs_paths)
continue
for root, dirs, files in os.walk(org_root):
dirs[:] = [d for d in dirs if not d.startswith(".")]
for name in files:
@@ -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,7 +1657,8 @@ class Handler(BaseHTTPRequestHandler):
re_read = params.get("re_read", ["0"])[0] == "1"
if re_read:
with S.lock:
folder = S.folder
folders = list(S.folders)
for folder in folders:
sync_metadata_from_folder(folder)
push_stats()
@@ -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,8 +2029,9 @@ 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} ...")
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=",
@@ -1941,14 +2041,14 @@ class Handler(BaseHTTPRequestHandler):
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]}")
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", "Deleted organized symlink directory.")
log("info", f"Deleted organized symlink directory in {folder}.")
except Exception as e:
log("error", f"Failed to delete _organized folder: {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)")