Add per-folder recursive/top-level-only scanning, fix a dangerous empty-folder-list fallback

- Each source folder now has its own recursive flag (checkbox on add,
  toggle badge on its chip). scan_folder/sync_metadata_from_folder both
  honor it, so "top-level only" actually excludes subfolders end-to-end
  instead of just the tagging queue while sync still scanned recursively.
- New /api/set_folder_recursive to flip an existing folder's mode without
  removing/re-adding it.
- SAFETY FIX: found while testing — when the configured folder list is
  empty, the app fell back to the parent directory of wherever server.py
  lives, which for a typical install (PHOTON dropped directly in the
  user's home folder) means defaulting to the entire home directory.
  Caught this before any scan/start actually ran against it. Fixed so an
  explicitly-configured empty list (config file exists, list is empty)
  is respected as "zero folders" instead of silently substituted with a
  guessed default — the guessed default now only applies on a genuinely
  fresh install where no config file exists yet at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-07-23 11:22:55 -07:00
parent 1f41ee5352
commit 501bfe2916
2 changed files with 161 additions and 57 deletions

185
server.py
View File

@@ -33,8 +33,12 @@ PORT = int(os.environ.get("PHOTON_PORT", 8765))
FOLDER_CONFIG_FILE = os.path.join(APP_DIR, "photon_folder.json")
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."""
"""Returns the explicitly configured folder list (as {"path", "recursive"}
dicts) if a config file exists — even if that list is empty, because the
user deliberately removed every folder and that must be respected, NOT
silently overridden by a fallback default. Returns None only when there
is no config file at all (a genuinely fresh install), which is the one
case the zero-config fallback below is allowed to kick in for."""
if os.path.exists(FOLDER_CONFIG_FILE):
try:
with open(FOLDER_CONFIG_FILE, "r", encoding="utf-8") as f:
@@ -42,18 +46,24 @@ def load_saved_folders():
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
if folders is not None:
out = []
for f in folders:
if isinstance(f, str):
f = {"path": f, "recursive": True}
if f.get("path") and os.path.isdir(f["path"]):
out.append({"path": f["path"], "recursive": bool(f.get("recursive", True))})
return out # explicit list, possibly empty — respected as-is
except Exception:
pass
return []
return None
def save_folders_config(folders):
def save_folders_config(folders, recursive_map):
try:
with open(FOLDER_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump({"folders": folders}, f)
json.dump({"folders": [
{"path": p, "recursive": recursive_map.get(p, True)} for p in folders
]}, f)
except Exception:
pass
@@ -66,21 +76,23 @@ def resolve_default_folders():
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 list.
Returns a list of {"path", "recursive"} dicts.
"""
if len(sys.argv) > 1:
arg = os.path.abspath(sys.argv[1])
if os.path.isdir(arg):
return [arg]
return [{"path": arg, "recursive": True}]
env = os.environ.get("PHOTON_FOLDER")
if env and os.path.isdir(env):
return [os.path.abspath(env)]
return [{"path": os.path.abspath(env), "recursive": True}]
saved = load_saved_folders()
if saved:
return saved
return [os.path.dirname(APP_DIR)]
if saved is not None:
return saved # respect an explicit (even empty) prior configuration
return [{"path": os.path.dirname(APP_DIR), "recursive": True}]
DEFAULT_FOLDERS = resolve_default_folders()
DEFAULT_FOLDER = DEFAULT_FOLDERS[0]
DEFAULT_FOLDERS_FULL = resolve_default_folders()
DEFAULT_FOLDERS = [f["path"] for f in DEFAULT_FOLDERS_FULL]
DEFAULT_FOLDER = DEFAULT_FOLDERS[0] if DEFAULT_FOLDERS else None
JOURNAL = os.path.join(APP_DIR, "photon_journal.jsonl")
CATEGORIES_FILE = os.path.join(APP_DIR, "photon_categories.json")
@@ -175,6 +187,7 @@ class State:
self.lock = threading.RLock()
self.status = "idle" # idle | scanning | running | paused | stopping | done
self.folders = list(DEFAULT_FOLDERS) # every configured source folder
self.folder_recursive = {f["path"]: f["recursive"] for f in DEFAULT_FOLDERS_FULL} # path -> include subfolders?
self.files = [] # pending image paths (after scan)
self.video_files = [] # video paths found on last scan
self.total_images = 0
@@ -762,31 +775,54 @@ def ollama_generate(model, prompt, img_b64, opts, keep_alive, think=None, schema
# ---------------------------------------------------------------- pipeline
def scan_folder(folder):
def scan_folder(folder, recursive=True):
images, videos, sidecars = [], [], 0
for root, dirs, files in os.walk(folder):
dirs[:] = [d for d in dirs if not d.startswith(".") and not d.startswith("_")]
for name in sorted(files):
if name.startswith("."): # ._AppleDouble & hidden files: never touch
if recursive:
for root, dirs, files in os.walk(folder):
dirs[:] = [d for d in dirs if not d.startswith(".") and not d.startswith("_")]
for name in sorted(files):
if name.startswith("."): # ._AppleDouble & hidden files: never touch
sidecars += 1
continue
ext = os.path.splitext(name)[1].lower()
if ext in IMAGE_EXTS:
images.append(os.path.join(root, name))
elif ext in VIDEO_EXTS:
videos.append(os.path.join(root, name))
else:
try:
names = sorted(os.listdir(folder))
except OSError:
names = []
for name in names:
full = os.path.join(folder, name)
if not os.path.isfile(full):
continue
if name.startswith("."):
sidecars += 1
continue
ext = os.path.splitext(name)[1].lower()
if ext in IMAGE_EXTS:
images.append(os.path.join(root, name))
images.append(full)
elif ext in VIDEO_EXTS:
videos.append(os.path.join(root, name))
videos.append(full)
return images, videos, sidecars
def folders_payload():
with S.lock:
return [{"path": f, "recursive": S.folder_recursive.get(f, True)} for f in S.folders]
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)."""
"""Scan every folder in S.folders (honoring each one's recursive flag)
and merge the results into the pending-work state, deduping any path
that shows up under more than one configured folder."""
with S.lock:
folders = list(S.folders)
recursive_map = dict(S.folder_recursive)
all_images, all_videos, total_sidecars = [], [], 0
seen = set()
for folder in folders:
images, videos, sidecars = scan_folder(folder)
images, videos, sidecars = scan_folder(folder, recursive_map.get(folder, True))
for p in images:
if p not in seen:
seen.add(p)
@@ -1015,9 +1051,10 @@ def write_metadata(path, desc, category, keep_backup, preserve_date):
if r.returncode != 0:
raise RuntimeError(f"exiftool: {r.stderr.decode(errors='replace')[:300]}")
def sync_metadata_from_folder(folder):
log("info", f"Syncing journal with folder metadata: {folder} ...")
cmd = ["exiftool", "-r", "-json", "-if",
def sync_metadata_from_folder(folder, recursive=True):
log("info", f"Syncing journal with folder metadata: {folder} "
f"({'with subfolders' if recursive else 'top-level only'}) ...")
cmd = ["exiftool"] + (["-r"] if recursive else []) + ["-json", "-if",
"$Subject =~ /photon-tagged/ or $Keywords =~ /photon-tagged/",
"-EXIF:ImageDescription", "-XMP-dc:Description", "-IPTC:Caption-Abstract", "-Description",
"-XMP-dc:Subject", "-IPTC:Keywords", folder]
@@ -1424,7 +1461,10 @@ class Handler(BaseHTTPRequestHandler):
elif self.path == "/api/state":
with S.lock:
payload = stats_payload()
payload.update({"folders": S.folders,
payload.update({"folders": [
{"path": f, "recursive": S.folder_recursive.get(f, True)}
for f in S.folders
],
"folder": S.folders[0] if S.folders else "",
"log": S.log_ring[-200:],
"categories": CATEGORIES, "current": S.current,
@@ -1658,8 +1698,9 @@ class Handler(BaseHTTPRequestHandler):
if re_read:
with S.lock:
folders = list(S.folders)
recursive_map = dict(S.folder_recursive)
for folder in folders:
sync_metadata_from_folder(folder)
sync_metadata_from_folder(folder, recursive_map.get(folder, True))
push_stats()
filtered = filtered_search_results(q, cat, date_from, date_to, sort, ftype)
@@ -1895,30 +1936,43 @@ class Handler(BaseHTTPRequestHandler):
if self.path == "/api/scan":
# {"folders": [...]} replaces the whole list; {"folder": "..."}
# (legacy single-folder form) replaces it with just that one.
# Each entry may be a plain path string (recursive=True) or
# {"path", "recursive"}.
requested = body.get("folders")
default_recursive = bool(body.get("recursive", True))
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:
requested = [{"path": single, "recursive": default_recursive}]
normalized = []
for f in requested:
if isinstance(f, str):
f = {"path": f, "recursive": default_recursive}
if f.get("path") and os.path.isdir(f["path"]):
normalized.append({"path": os.path.abspath(f["path"]),
"recursive": bool(f.get("recursive", True))})
if not normalized:
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.folders = valid
save_folders_config(valid)
S.status = "scanning"
S.folders = [f["path"] for f in normalized]
S.folder_recursive = {f["path"]: f["recursive"] for f in normalized}
save_folders_config(S.folders, S.folder_recursive)
broadcast("state", {"status": "scanning"})
log("info", f"scanning {len(valid)} folder(s): {', '.join(valid)} …")
log("info", f"scanning {len(normalized)} folder(s): "
f"{', '.join(f['path'] + ('' if f['recursive'] else ' [top-level only]') for f in normalized)} …")
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({"folders": valid, "images": len(images), "videos": len(videos),
self._json({"folders": folders_payload(), "images": len(images), "videos": len(videos),
"sidecars": sidecars, "alreadyDone": done})
elif self.path == "/api/add_folder":
folder = body.get("folder")
recursive = bool(body.get("recursive", True))
if not folder or not os.path.isdir(folder):
self._json({"error": f"not a folder: {folder}"}, 400); return
with S.lock:
@@ -1929,14 +1983,16 @@ class Handler(BaseHTTPRequestHandler):
already = any(os.path.abspath(f) == folder for f in S.folders)
if not already:
S.folders.append(folder)
S.folder_recursive[folder] = recursive
folders = list(S.folders)
save_folders_config(folders)
log("info", f"{'already tracking' if already else 'added'} folder: {folder} — rescanning all …")
save_folders_config(folders, S.folder_recursive)
log("info", f"{'already tracking' if already else 'added'} folder: {folder} "
f"({'with subfolders' if recursive else 'top-level only'}) — rescanning all …")
images, videos, sidecars, done = rescan_configured_folders()
sync_metadata_from_folder(folder)
sync_metadata_from_folder(folder, recursive)
load_journal()
push_stats()
self._json({"ok": True, "folders": folders, "images": len(images),
self._json({"ok": True, "folders": folders_payload(), "images": len(images),
"videos": len(videos), "sidecars": sidecars, "alreadyDone": done})
elif self.path == "/api/remove_folder":
@@ -1949,15 +2005,43 @@ class Handler(BaseHTTPRequestHandler):
folder = os.path.abspath(folder)
with S.lock:
S.folders = [f for f in S.folders if os.path.abspath(f) != folder]
S.folder_recursive.pop(folder, None)
folders = list(S.folders)
save_folders_config(folders)
save_folders_config(folders, S.folder_recursive)
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),
self._json({"ok": True, "folders": folders_payload(), "images": len(images),
"videos": len(videos), "sidecars": sidecars, "alreadyDone": done})
elif self.path == "/api/set_folder_recursive":
folder = body.get("folder")
recursive = bool(body.get("recursive", True))
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 changing a folder's scan mode"}, 400); return
folder = os.path.abspath(folder)
with S.lock:
if not any(os.path.abspath(f) == folder for f in S.folders):
self._json({"error": "that folder isn't configured"}, 400); return
S.folder_recursive[folder] = recursive
folders = list(S.folders)
save_folders_config(folders, S.folder_recursive)
log("info", f"{folder} set to {'include subfolders' if recursive else 'top-level only'} — rescanning …")
images, videos, sidecars, done = rescan_configured_folders()
if recursive:
# newly in-scope subfolder files might already carry embedded
# PHOTON tags (e.g. from a previous session) — detect them now
sync_metadata_from_folder(folder, recursive)
load_journal()
push_stats()
self._json({"ok": True, "folders": folders_payload(), "images": len(images),
"videos": len(videos), "sidecars": sidecars, "alreadyDone": done})
elif self.path == "/api/choose_folder":
recursive = bool(body.get("recursive", True))
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 "Add a photo folder:")']
r = subprocess.run(cmd, capture_output=True, timeout=60)
@@ -1969,13 +2053,14 @@ class Handler(BaseHTTPRequestHandler):
already = any(os.path.abspath(f) == chosen for f in S.folders)
if not already:
S.folders.append(chosen)
S.folder_recursive[chosen] = recursive
folders = list(S.folders)
save_folders_config(folders)
save_folders_config(folders, S.folder_recursive)
images, videos, sidecars, done = rescan_configured_folders()
sync_metadata_from_folder(chosen)
sync_metadata_from_folder(chosen, recursive)
load_journal()
push_stats()
self._json({"ok": True, "folder": chosen, "folders": folders,
self._json({"ok": True, "folder": chosen, "folders": folders_payload(),
"images": len(images), "videos": len(videos), "alreadyDone": done})
return
self._json({"error": "No folder selected or dialog cancelled"}, 400)
@@ -2367,8 +2452,8 @@ def main():
load_failures()
load_phash_cache()
print(f"PHOTON console → http://localhost:{PORT}")
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 ""))
print(f"photo folder(s) → {', '.join(DEFAULT_FOLDERS) if DEFAULT_FOLDERS else '(none configured — add one from the Console tab)'}"
+ (" (auto-detected — change it anytime from the Console tab)" if DEFAULT_FOLDERS and 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)")
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()