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

View File

@@ -89,6 +89,14 @@ button:disabled{opacity:.3;cursor:not-allowed}
.folderChip .rm{cursor:pointer;color:var(--dim);border:1px solid var(--line);border-radius:50%; .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} 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)} .folderChip .rm:hover{color:var(--red);border-color:var(--red)}
.folderChip .mode{cursor:pointer;color:var(--dim);border:1px solid var(--line);border-radius:8px;
padding:1px 7px;font-size:10px;white-space:nowrap}
.folderChip .mode:hover{color:var(--cyan);border-color:var(--cyan)}
.folderChip .mode.rec{color:var(--cyan-dim)}
.chkRecursiveLabel{display:flex;align-items:center;gap:5px;font-family:var(--font-mono);
font-size:11px;color:var(--dim);white-space:nowrap;padding:0 4px}
.chkRecursiveLabel input{accent-color:var(--cyan)}
.grid{display:grid;grid-template-columns:330px 1fr;gap:14px} .grid{display:grid;grid-template-columns:330px 1fr;gap:14px}
.grid-three{display:grid;grid-template-columns:330px 1fr 320px;gap:14px} .grid-three{display:grid;grid-template-columns:330px 1fr 320px;gap:14px}
@@ -352,6 +360,9 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
<div class="controls"> <div class="controls">
<div class="folderRow"> <div class="folderRow">
<input id="folder" spellcheck="false" placeholder="/path/to/your/photos — add as many source folders as you like"> <input id="folder" spellcheck="false" placeholder="/path/to/your/photos — add as many source folders as you like">
<label class="chkRecursiveLabel" title="If off, only the exact folder is scanned — no subfolders">
<input type="checkbox" id="chkRecursive" checked> include subfolders
</label>
<button id="btnAddFolderTyped" title="Add this typed path as a source folder">+ Add</button> <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> <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"> <input type="file" id="fallbackFolderInput" webkitdirectory style="display:none">
@@ -1021,7 +1032,7 @@ async function api(path, body){
} }
/* ---------- multi-folder source list ---------- */ /* ---------- multi-folder source list ---------- */
let currentFolders = []; let currentFolders = []; // [{path, recursive}, ...]
function renderFolderChips(){ function renderFolderChips(){
const box = $("folderChips"); const box = $("folderChips");
@@ -1030,8 +1041,11 @@ function renderFolderChips(){
currentFolders.forEach(f=>{ currentFolders.forEach(f=>{
const chip = document.createElement("div"); const chip = document.createElement("div");
chip.className = "folderChip"; chip.className = "folderChip";
chip.innerHTML = `<span class="path" title="${escapeHtml(f)}">${escapeHtml(f)}</span><span class="rm" title="Remove this folder">×</span>`; chip.innerHTML = `<span class="path" title="${escapeHtml(f.path)}">${escapeHtml(f.path)}</span>
chip.querySelector(".rm").onclick = ()=>removeFolder(f); <span class="mode ${f.recursive?'rec':''}" title="Click to toggle: currently ${f.recursive?'including subfolders':'top-level only'}">${f.recursive?'⊞ subfolders':'▭ top-level only'}</span>
<span class="rm" title="Remove this folder">×</span>`;
chip.querySelector(".rm").onclick = ()=>removeFolder(f.path);
chip.querySelector(".mode").onclick = ()=>toggleFolderRecursive(f);
box.appendChild(chip); box.appendChild(chip);
}); });
} }
@@ -1045,8 +1059,13 @@ function applyFolderList(folders){
} }
} }
async function removeFolder(f){ async function removeFolder(path){
const j = await api("/api/remove_folder", {folder: f}); const j = await api("/api/remove_folder", {folder: path});
if(j.ok) applyFolderList(j.folders);
}
async function toggleFolderRecursive(f){
const j = await api("/api/set_folder_recursive", {folder: f.path, recursive: !f.recursive});
if(j.ok) applyFolderList(j.folders); if(j.ok) applyFolderList(j.folders);
} }
@@ -1055,7 +1074,7 @@ if($("btnAddFolderTyped")){
const val = $("folder").value.trim(); const val = $("folder").value.trim();
if(!val) return; if(!val) return;
$("btnAddFolderTyped").disabled = true; $("btnAddFolderTyped").disabled = true;
const j = await api("/api/add_folder", {folder: val}); const j = await api("/api/add_folder", {folder: val, recursive: $("chkRecursive").checked});
$("btnAddFolderTyped").disabled = false; $("btnAddFolderTyped").disabled = false;
if(j.ok){ if(j.ok){
$("folder").value = ""; $("folder").value = "";
@@ -1069,7 +1088,7 @@ if($("btnBrowseFolder")){
$("btnBrowseFolder").onclick = async ()=>{ $("btnBrowseFolder").onclick = async ()=>{
$("btnBrowseFolder").disabled = true; $("btnBrowseFolder").disabled = true;
showToast("Opening folder chooser…", "info"); showToast("Opening folder chooser…", "info");
const j = await api("/api/choose_folder", {}); const j = await api("/api/choose_folder", {recursive: $("chkRecursive").checked});
$("btnBrowseFolder").disabled = false; $("btnBrowseFolder").disabled = false;
if(j.ok && j.folder){ if(j.ok && j.folder){
showToast("Added folder: " + j.folder, "ok"); showToast("Added folder: " + j.folder, "ok");

173
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") FOLDER_CONFIG_FILE = os.path.join(APP_DIR, "photon_folder.json")
def load_saved_folders(): def load_saved_folders():
"""Returns the list of configured source folders, migrating transparently """Returns the explicitly configured folder list (as {"path", "recursive"}
from the old single-folder config format if that's all that's on disk.""" 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): if os.path.exists(FOLDER_CONFIG_FILE):
try: try:
with open(FOLDER_CONFIG_FILE, "r", encoding="utf-8") as f: with open(FOLDER_CONFIG_FILE, "r", encoding="utf-8") as f:
@@ -42,18 +46,24 @@ def load_saved_folders():
folders = data.get("folders") folders = data.get("folders")
if folders is None and data.get("folder"): if folders is None and data.get("folder"):
folders = [data["folder"]] folders = [data["folder"]]
if folders: if folders is not None:
existing = [f for f in folders if os.path.isdir(f)] out = []
if existing: for f in folders:
return existing 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: except Exception:
pass pass
return [] return None
def save_folders_config(folders): def save_folders_config(folders, recursive_map):
try: try:
with open(FOLDER_CONFIG_FILE, "w", encoding="utf-8") as f: 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: except Exception:
pass pass
@@ -66,21 +76,23 @@ def resolve_default_folders():
workflow is dropping the PHOTON folder directly inside a photo workflow is dropping the PHOTON folder directly inside a photo
collection, so its parent IS that collection. collection, so its parent IS that collection.
Always overridable from the Console tab's folder list. Always overridable from the Console tab's folder list.
Returns a list of {"path", "recursive"} dicts.
""" """
if len(sys.argv) > 1: if len(sys.argv) > 1:
arg = os.path.abspath(sys.argv[1]) arg = os.path.abspath(sys.argv[1])
if os.path.isdir(arg): if os.path.isdir(arg):
return [arg] return [{"path": arg, "recursive": True}]
env = os.environ.get("PHOTON_FOLDER") env = os.environ.get("PHOTON_FOLDER")
if env and os.path.isdir(env): if env and os.path.isdir(env):
return [os.path.abspath(env)] return [{"path": os.path.abspath(env), "recursive": True}]
saved = load_saved_folders() saved = load_saved_folders()
if saved: if saved is not None:
return saved return saved # respect an explicit (even empty) prior configuration
return [os.path.dirname(APP_DIR)] return [{"path": os.path.dirname(APP_DIR), "recursive": True}]
DEFAULT_FOLDERS = resolve_default_folders() DEFAULT_FOLDERS_FULL = resolve_default_folders()
DEFAULT_FOLDER = DEFAULT_FOLDERS[0] 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") JOURNAL = os.path.join(APP_DIR, "photon_journal.jsonl")
CATEGORIES_FILE = os.path.join(APP_DIR, "photon_categories.json") CATEGORIES_FILE = os.path.join(APP_DIR, "photon_categories.json")
@@ -175,6 +187,7 @@ class State:
self.lock = threading.RLock() self.lock = threading.RLock()
self.status = "idle" # idle | scanning | running | paused | stopping | done self.status = "idle" # idle | scanning | running | paused | stopping | done
self.folders = list(DEFAULT_FOLDERS) # every configured source folder 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.files = [] # pending image paths (after scan)
self.video_files = [] # video paths found on last scan self.video_files = [] # video paths found on last scan
self.total_images = 0 self.total_images = 0
@@ -762,8 +775,9 @@ def ollama_generate(model, prompt, img_b64, opts, keep_alive, think=None, schema
# ---------------------------------------------------------------- pipeline # ---------------------------------------------------------------- pipeline
def scan_folder(folder): def scan_folder(folder, recursive=True):
images, videos, sidecars = [], [], 0 images, videos, sidecars = [], [], 0
if recursive:
for root, dirs, files in os.walk(folder): for root, dirs, files in os.walk(folder):
dirs[:] = [d for d in dirs if not d.startswith(".") and not d.startswith("_")] dirs[:] = [d for d in dirs if not d.startswith(".") and not d.startswith("_")]
for name in sorted(files): for name in sorted(files):
@@ -775,18 +789,40 @@ def scan_folder(folder):
images.append(os.path.join(root, name)) images.append(os.path.join(root, name))
elif ext in VIDEO_EXTS: elif ext in VIDEO_EXTS:
videos.append(os.path.join(root, name)) 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(full)
elif ext in VIDEO_EXTS:
videos.append(full)
return images, videos, sidecars 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(): def rescan_configured_folders():
"""Scan every folder in S.folders and merge the results into the """Scan every folder in S.folders (honoring each one's recursive flag)
pending-work state, deduping any path that shows up under more than and merge the results into the pending-work state, deduping any path
one configured folder (e.g. one nested inside another).""" that shows up under more than one configured folder."""
with S.lock: with S.lock:
folders = list(S.folders) folders = list(S.folders)
recursive_map = dict(S.folder_recursive)
all_images, all_videos, total_sidecars = [], [], 0 all_images, all_videos, total_sidecars = [], [], 0
seen = set() seen = set()
for folder in folders: 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: for p in images:
if p not in seen: if p not in seen:
seen.add(p) seen.add(p)
@@ -1015,9 +1051,10 @@ def write_metadata(path, desc, category, keep_backup, preserve_date):
if r.returncode != 0: if r.returncode != 0:
raise RuntimeError(f"exiftool: {r.stderr.decode(errors='replace')[:300]}") raise RuntimeError(f"exiftool: {r.stderr.decode(errors='replace')[:300]}")
def sync_metadata_from_folder(folder): def sync_metadata_from_folder(folder, recursive=True):
log("info", f"Syncing journal with folder metadata: {folder} ...") log("info", f"Syncing journal with folder metadata: {folder} "
cmd = ["exiftool", "-r", "-json", "-if", f"({'with subfolders' if recursive else 'top-level only'}) ...")
cmd = ["exiftool"] + (["-r"] if recursive else []) + ["-json", "-if",
"$Subject =~ /photon-tagged/ or $Keywords =~ /photon-tagged/", "$Subject =~ /photon-tagged/ or $Keywords =~ /photon-tagged/",
"-EXIF:ImageDescription", "-XMP-dc:Description", "-IPTC:Caption-Abstract", "-Description", "-EXIF:ImageDescription", "-XMP-dc:Description", "-IPTC:Caption-Abstract", "-Description",
"-XMP-dc:Subject", "-IPTC:Keywords", folder] "-XMP-dc:Subject", "-IPTC:Keywords", folder]
@@ -1424,7 +1461,10 @@ class Handler(BaseHTTPRequestHandler):
elif self.path == "/api/state": elif self.path == "/api/state":
with S.lock: with S.lock:
payload = stats_payload() 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 "", "folder": S.folders[0] if S.folders else "",
"log": S.log_ring[-200:], "log": S.log_ring[-200:],
"categories": CATEGORIES, "current": S.current, "categories": CATEGORIES, "current": S.current,
@@ -1658,8 +1698,9 @@ class Handler(BaseHTTPRequestHandler):
if re_read: if re_read:
with S.lock: with S.lock:
folders = list(S.folders) folders = list(S.folders)
recursive_map = dict(S.folder_recursive)
for folder in folders: for folder in folders:
sync_metadata_from_folder(folder) sync_metadata_from_folder(folder, recursive_map.get(folder, True))
push_stats() push_stats()
filtered = filtered_search_results(q, cat, date_from, date_to, sort, ftype) filtered = filtered_search_results(q, cat, date_from, date_to, sort, ftype)
@@ -1895,30 +1936,43 @@ class Handler(BaseHTTPRequestHandler):
if self.path == "/api/scan": if self.path == "/api/scan":
# {"folders": [...]} replaces the whole list; {"folder": "..."} # {"folders": [...]} replaces the whole list; {"folder": "..."}
# (legacy single-folder form) replaces it with just that one. # (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") requested = body.get("folders")
default_recursive = bool(body.get("recursive", True))
if not requested: if not requested:
single = body.get("folder") or DEFAULT_FOLDER single = body.get("folder") or DEFAULT_FOLDER
requested = [single] requested = [{"path": single, "recursive": default_recursive}]
valid = [os.path.abspath(f) for f in requested if f and os.path.isdir(f)] normalized = []
if not valid: 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 self._json({"error": f"no valid folder in: {requested}"}, 400); return
with S.lock: with S.lock:
if S.status == "running": if S.status == "running":
self._json({"error": "stop the run before rescanning"}, 400); return self._json({"error": "stop the run before rescanning"}, 400); return
S.status = "scanning"; S.folders = valid S.status = "scanning"
save_folders_config(valid) 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"}) 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() images, videos, sidecars, done = rescan_configured_folders()
log("ok", f"scan complete: {len(images)} images | {len(videos)} videos found | " log("ok", f"scan complete: {len(images)} images | {len(videos)} videos found | "
f"{sidecars} hidden/sidecar files ignored | {done} already tagged") f"{sidecars} hidden/sidecar files ignored | {done} already tagged")
broadcast("state", {"status": "idle"}) broadcast("state", {"status": "idle"})
push_stats() 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}) "sidecars": sidecars, "alreadyDone": done})
elif self.path == "/api/add_folder": elif self.path == "/api/add_folder":
folder = body.get("folder") folder = body.get("folder")
recursive = bool(body.get("recursive", True))
if not folder or not os.path.isdir(folder): if not folder or not os.path.isdir(folder):
self._json({"error": f"not a folder: {folder}"}, 400); return self._json({"error": f"not a folder: {folder}"}, 400); return
with S.lock: with S.lock:
@@ -1929,14 +1983,16 @@ class Handler(BaseHTTPRequestHandler):
already = any(os.path.abspath(f) == folder for f in S.folders) already = any(os.path.abspath(f) == folder for f in S.folders)
if not already: if not already:
S.folders.append(folder) S.folders.append(folder)
S.folder_recursive[folder] = recursive
folders = list(S.folders) folders = list(S.folders)
save_folders_config(folders) save_folders_config(folders, S.folder_recursive)
log("info", f"{'already tracking' if already else 'added'} folder: {folder} — rescanning all …") 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() images, videos, sidecars, done = rescan_configured_folders()
sync_metadata_from_folder(folder) sync_metadata_from_folder(folder, recursive)
load_journal() load_journal()
push_stats() 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}) "videos": len(videos), "sidecars": sidecars, "alreadyDone": done})
elif self.path == "/api/remove_folder": elif self.path == "/api/remove_folder":
@@ -1949,15 +2005,43 @@ class Handler(BaseHTTPRequestHandler):
folder = os.path.abspath(folder) folder = os.path.abspath(folder)
with S.lock: with S.lock:
S.folders = [f for f in S.folders if os.path.abspath(f) != folder] S.folders = [f for f in S.folders if os.path.abspath(f) != folder]
S.folder_recursive.pop(folder, None)
folders = list(S.folders) 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) …") log("info", f"removed folder: {folder} — rescanning remaining {len(folders)} folder(s) …")
images, videos, sidecars, done = rescan_configured_folders() images, videos, sidecars, done = rescan_configured_folders()
push_stats() 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}) "videos": len(videos), "sidecars": sidecars, "alreadyDone": done})
elif self.path == "/api/choose_folder": elif self.path == "/api/choose_folder":
recursive = bool(body.get("recursive", True))
try: 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:")'] 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) 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) already = any(os.path.abspath(f) == chosen for f in S.folders)
if not already: if not already:
S.folders.append(chosen) S.folders.append(chosen)
S.folder_recursive[chosen] = recursive
folders = list(S.folders) folders = list(S.folders)
save_folders_config(folders) save_folders_config(folders, S.folder_recursive)
images, videos, sidecars, done = rescan_configured_folders() images, videos, sidecars, done = rescan_configured_folders()
sync_metadata_from_folder(chosen) sync_metadata_from_folder(chosen, recursive)
load_journal() load_journal()
push_stats() 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}) "images": len(images), "videos": len(videos), "alreadyDone": done})
return return
self._json({"error": "No folder selected or dialog cancelled"}, 400) self._json({"error": "No folder selected or dialog cancelled"}, 400)
@@ -2367,8 +2452,8 @@ def main():
load_failures() load_failures()
load_phash_cache() load_phash_cache()
print(f"PHOTON console → http://localhost:{PORT}") print(f"PHOTON console → http://localhost:{PORT}")
print(f"photo folder(s) → {', '.join(DEFAULT_FOLDERS)}" 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 len(sys.argv) <= 1 and not os.environ.get("PHOTON_FOLDER") else "")) + (" (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: if n:
print(f"journal loaded: {n} photos already tagged (will be skipped on resume)") print(f"journal loaded: {n} photos already tagged (will be skipped on resume)")
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()