`;
+ if(star){
+ return `★ Looks like ${esc(name)}${thumb}${pop}`;
+ }
+ return `${thumb}${label}${pop}`;
+}
+
function fmtTs(ts){return new Date(ts*1000).toLocaleString(undefined,{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})}
// Wann das Ereignis passierte, nicht wann wir es verarbeitet haben — beim Verlaufs-Scan
// liegen die Wochen auseinander.
@@ -298,7 +329,7 @@
`;
+ if(star){
+ return `★ Looks like ${esc(name)}${thumb}${pop}`;
+ }
+ return `${thumb}${label}${pop}`;
+}
+
function fmtTs(ts){return new Date(ts*1000).toLocaleString(undefined,{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})}
// Wann das Ereignis passierte, nicht wann wir es verarbeitet haben — beim Verlaufs-Scan
// liegen die Wochen auseinander.
@@ -298,7 +329,7 @@
CLUSTER ${String(i+1).padStart(2,'0')}${c.length} capture${c.length>1?'s':''} · ${esc(c[0].camera||'')}
- ${c[0].guess?guessCue(c[0].guess,(c[0].guess_score*100|0),gslug,c[0].guess_top_photo||'',false):''}
+ ${c[0].guess?guessCue(c[0].guess,(c[0].guess_score*100|0),c[0].guess_top_slug||gslug,c[0].guess_top_photo||'',false):''}
From dd581e2efbdf61c4a5476b00ecbff71bec4a2d25 Mon Sep 17 00:00:00 2001
From: Alien10140 <4649783+Alien10140@users.noreply.github.com>
Date: Wed, 7 Oct 2026 02:35:50 +0000
Subject: [PATCH 3/8] Drop stray blank line in CHANGELOG before 0.24.0
Co-authored-by: Cursor
---
CHANGELOG.md | 1 -
1 file changed, 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83e2248..7c9065c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -82,7 +82,6 @@ update dialog; standalone users can watch GitHub releases.
wrote them into the generated config — the same defect as #24, in three more fields. Found
by a new test that checks every option reaches the generated file.
-
## 0.24.0 — 2026-09-15
- **The folder input no longer remembers every file forever.** `data/folder_ingest.json`
From 4af4d0effbca47c695a62cbe2a1c3a7101d785d0 Mon Sep 17 00:00:00 2001
From: Alien10140 <4649783+Alien10140@users.noreply.github.com>
Date: Wed, 7 Oct 2026 02:38:00 +0000
Subject: [PATCH 4/8] Refresh looks-like cues when gallery photos change
Call refresh_guesses after person/photo delete, set-aside, restore, and
non-dry dedupe so stored guess_top_slug/photo pointers do not dangle.
Co-authored-by: Cursor
---
CHANGELOG.md | 2 +-
app/webui.py | 10 +++++++++-
faceid-addon/app/webui.py | 10 +++++++++-
3 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c9065c..e1df89e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@ update dialog; standalone users can watch GitHub releases.
## Unreleased
-- **Looks-like cues show the gallery face.** Unknown review now stores `guess_top_slug` + `guess_top_photo` (the reference that best explains a guess) and shows a thumbnail next to *looks like* / ★ Looks like headers; hover enlarges, click opens the existing full viewer. Keys are omitted when there is no photo.
+- **Looks-like cues show the gallery face.** Unknown review now stores `guess_top_slug` + `guess_top_photo` (the reference that best explains a guess) and shows a thumbnail next to *looks like* / ★ Looks like headers; hover enlarges, click opens the existing full viewer. Keys are omitted when there is no photo; deleting or setting aside a reference photo refreshes dangling cues.
## 0.25.1 — 2026-10-06
diff --git a/app/webui.py b/app/webui.py
index 633d2d4..f476b93 100644
--- a/app/webui.py
+++ b/app/webui.py
@@ -77,6 +77,7 @@ def create_person(body: NameBody):
@app.delete("/api/persons/{slug}")
def delete_person(slug: str):
gallery.delete_person(slug)
+ gallery.refresh_guesses()
return {"ok": True}
class FavBody(BaseModel):
@@ -149,6 +150,7 @@ def set_aside_face(slug: str, fname: str, body: AsideBody):
if not gallery.set_aside(slug, fname, body.reason):
raise HTTPException(404, "unknown person or photo")
log.info("%s/%s set aside — %s", slug, fname, body.reason)
+ gallery.refresh_guesses()
return {"ok": True}
@app.post("/api/persons/{slug}/rename")
@@ -169,7 +171,10 @@ def set_favorite(slug: str, body: FavBody):
@app.post("/api/persons/{slug}/trimmed/{fname}/restore")
def restore_trimmed(slug: str, fname: str):
- return {"ok": gallery.restore_trimmed(slug, fname)}
+ ok = gallery.restore_trimmed(slug, fname)
+ if ok:
+ gallery.refresh_guesses()
+ return {"ok": ok}
@app.delete("/api/persons/{slug}/trimmed/{fname}")
def delete_trimmed(slug: str, fname: str):
@@ -188,12 +193,15 @@ def deduplicate(body: dict = None):
# zuerst echte Bild-Dubletten (identisches Foto), dann aehnliche Gesichter
pix = gallery.deduplicate_pixels_all(dry_run=dry)
emb = gallery.deduplicate_all(thr, dry_run=dry)
+ if not dry and (pix or emb):
+ gallery.refresh_guesses()
key = "would_remove" if dry else "moved"
return {key: pix + emb, "same_image": pix, "similar_face": emb, "threshold": thr}
@app.delete("/api/persons/{slug}/faces/{fname}")
def delete_face(slug: str, fname: str):
gallery.delete_face(slug, fname)
+ gallery.refresh_guesses()
return {"ok": True}
@app.post("/api/persons/{slug}/faces/{fname}/unassign")
diff --git a/faceid-addon/app/webui.py b/faceid-addon/app/webui.py
index 633d2d4..f476b93 100644
--- a/faceid-addon/app/webui.py
+++ b/faceid-addon/app/webui.py
@@ -77,6 +77,7 @@ def create_person(body: NameBody):
@app.delete("/api/persons/{slug}")
def delete_person(slug: str):
gallery.delete_person(slug)
+ gallery.refresh_guesses()
return {"ok": True}
class FavBody(BaseModel):
@@ -149,6 +150,7 @@ def set_aside_face(slug: str, fname: str, body: AsideBody):
if not gallery.set_aside(slug, fname, body.reason):
raise HTTPException(404, "unknown person or photo")
log.info("%s/%s set aside — %s", slug, fname, body.reason)
+ gallery.refresh_guesses()
return {"ok": True}
@app.post("/api/persons/{slug}/rename")
@@ -169,7 +171,10 @@ def set_favorite(slug: str, body: FavBody):
@app.post("/api/persons/{slug}/trimmed/{fname}/restore")
def restore_trimmed(slug: str, fname: str):
- return {"ok": gallery.restore_trimmed(slug, fname)}
+ ok = gallery.restore_trimmed(slug, fname)
+ if ok:
+ gallery.refresh_guesses()
+ return {"ok": ok}
@app.delete("/api/persons/{slug}/trimmed/{fname}")
def delete_trimmed(slug: str, fname: str):
@@ -188,12 +193,15 @@ def deduplicate(body: dict = None):
# zuerst echte Bild-Dubletten (identisches Foto), dann aehnliche Gesichter
pix = gallery.deduplicate_pixels_all(dry_run=dry)
emb = gallery.deduplicate_all(thr, dry_run=dry)
+ if not dry and (pix or emb):
+ gallery.refresh_guesses()
key = "would_remove" if dry else "moved"
return {key: pix + emb, "same_image": pix, "similar_face": emb, "threshold": thr}
@app.delete("/api/persons/{slug}/faces/{fname}")
def delete_face(slug: str, fname: str):
gallery.delete_face(slug, fname)
+ gallery.refresh_guesses()
return {"ok": True}
@app.post("/api/persons/{slug}/faces/{fname}/unassign")
From 27c47a437f122b5c2f8c8a0811603462b0a6df3a Mon Sep 17 00:00:00 2001
From: Alien10140 <4649783+Alien10140@users.noreply.github.com>
Date: Wed, 7 Oct 2026 02:41:56 +0000
Subject: [PATCH 5/8] Debounce gallery cue refresh; write unknown JSON
atomically
request_refresh_guesses coalesces delete/set-aside/dedupe bursts into one
background refresh_guesses pass, and each metadata rewrite uses temp+replace
so a crash cannot truncate an unknown's embedding JSON.
Co-authored-by: Cursor
---
app/gallery.py | 50 ++++++++++++++++++++++++++++++++++++-
app/webui.py | 10 ++++----
faceid-addon/app/gallery.py | 50 ++++++++++++++++++++++++++++++++++++-
faceid-addon/app/webui.py | 10 ++++----
4 files changed, 108 insertions(+), 12 deletions(-)
diff --git a/app/gallery.py b/app/gallery.py
index f486abf..6aefcd9 100644
--- a/app/gallery.py
+++ b/app/gallery.py
@@ -5,6 +5,7 @@
"""
import hashlib
import json
+import os
import logging
import re
import shutil
@@ -65,6 +66,9 @@ def __init__(self, data_dir: Path, top_k: int = 3, max_per_person: int = 40):
self.self_outlier_min_photos = 5 # darunter ist der Median nicht belastbar
self.dedupe_threshold = 0.65 # ab hier gilt ein Foto als Duplikat (Hover-Highlight + Dedup)
self._lock = threading.Lock()
+ self._refresh_sched_lock = threading.Lock()
+ self._refresh_deadline = None
+ self._refresh_thread = None
self._cache = {} # slug -> {"name":..., "emb": np.ndarray, "files": [...]}
self._ign_emb = np.zeros((0, 512), dtype=np.float32)
self._ign_ids: list[str] = []
@@ -1052,7 +1056,51 @@ def refresh_guesses(self):
else:
m.pop("guess_top_photo", None)
m.pop("guess_top_slug", None)
- jf.write_text(json.dumps(m, ensure_ascii=False))
+ # Atomic replace — a crashed mid-write would otherwise truncate the
+ # JSON (and the embedding inside it) for a silent loss on next load.
+ raw = json.dumps(m, ensure_ascii=False)
+ tmp = jf.with_suffix(".json.tmp")
+ tmp.write_text(raw)
+ os.replace(tmp, jf)
+
+ def request_refresh_guesses(self, delay: float = 0.4):
+ """Coalesce refresh_guesses from request handlers (delete / set-aside / dedupe).
+
+ Each refresh re-matches every unknown; firing it once per deleted photo
+ is O(N × unknowns) on the HTTP thread. A short debounce collapses a
+ cleanup burst into one background pass.
+ """
+ with self._refresh_sched_lock:
+ self._refresh_deadline = time.time() + delay
+ if self._refresh_thread is not None and self._refresh_thread.is_alive():
+ return
+ t = threading.Thread(target=self._refresh_guesses_worker, name="refresh-guesses",
+ daemon=True)
+ self._refresh_thread = t
+ t.start()
+
+ def _refresh_guesses_worker(self):
+ while True:
+ with self._refresh_sched_lock:
+ deadline = self._refresh_deadline
+ if deadline is None:
+ return
+ wait = deadline - time.time()
+ if wait > 0:
+ time.sleep(wait)
+ continue
+ with self._refresh_sched_lock:
+ if self._refresh_deadline is not None and time.time() < self._refresh_deadline:
+ continue
+ self._refresh_deadline = None
+ try:
+ self.refresh_guesses()
+ except Exception:
+ log.exception("background refresh_guesses failed")
+ with self._refresh_sched_lock:
+ if self._refresh_deadline is None:
+ self._refresh_thread = None
+ return
def discard_unknown(self, uid: str):
(self.unknown_dir / f"{uid}.json").unlink(missing_ok=True)
diff --git a/app/webui.py b/app/webui.py
index f476b93..5291d7d 100644
--- a/app/webui.py
+++ b/app/webui.py
@@ -77,7 +77,7 @@ def create_person(body: NameBody):
@app.delete("/api/persons/{slug}")
def delete_person(slug: str):
gallery.delete_person(slug)
- gallery.refresh_guesses()
+ gallery.request_refresh_guesses()
return {"ok": True}
class FavBody(BaseModel):
@@ -150,7 +150,7 @@ def set_aside_face(slug: str, fname: str, body: AsideBody):
if not gallery.set_aside(slug, fname, body.reason):
raise HTTPException(404, "unknown person or photo")
log.info("%s/%s set aside — %s", slug, fname, body.reason)
- gallery.refresh_guesses()
+ gallery.request_refresh_guesses()
return {"ok": True}
@app.post("/api/persons/{slug}/rename")
@@ -173,7 +173,7 @@ def set_favorite(slug: str, body: FavBody):
def restore_trimmed(slug: str, fname: str):
ok = gallery.restore_trimmed(slug, fname)
if ok:
- gallery.refresh_guesses()
+ gallery.request_refresh_guesses()
return {"ok": ok}
@app.delete("/api/persons/{slug}/trimmed/{fname}")
@@ -194,14 +194,14 @@ def deduplicate(body: dict = None):
pix = gallery.deduplicate_pixels_all(dry_run=dry)
emb = gallery.deduplicate_all(thr, dry_run=dry)
if not dry and (pix or emb):
- gallery.refresh_guesses()
+ gallery.request_refresh_guesses()
key = "would_remove" if dry else "moved"
return {key: pix + emb, "same_image": pix, "similar_face": emb, "threshold": thr}
@app.delete("/api/persons/{slug}/faces/{fname}")
def delete_face(slug: str, fname: str):
gallery.delete_face(slug, fname)
- gallery.refresh_guesses()
+ gallery.request_refresh_guesses()
return {"ok": True}
@app.post("/api/persons/{slug}/faces/{fname}/unassign")
diff --git a/faceid-addon/app/gallery.py b/faceid-addon/app/gallery.py
index f486abf..6aefcd9 100644
--- a/faceid-addon/app/gallery.py
+++ b/faceid-addon/app/gallery.py
@@ -5,6 +5,7 @@
"""
import hashlib
import json
+import os
import logging
import re
import shutil
@@ -65,6 +66,9 @@ def __init__(self, data_dir: Path, top_k: int = 3, max_per_person: int = 40):
self.self_outlier_min_photos = 5 # darunter ist der Median nicht belastbar
self.dedupe_threshold = 0.65 # ab hier gilt ein Foto als Duplikat (Hover-Highlight + Dedup)
self._lock = threading.Lock()
+ self._refresh_sched_lock = threading.Lock()
+ self._refresh_deadline = None
+ self._refresh_thread = None
self._cache = {} # slug -> {"name":..., "emb": np.ndarray, "files": [...]}
self._ign_emb = np.zeros((0, 512), dtype=np.float32)
self._ign_ids: list[str] = []
@@ -1052,7 +1056,51 @@ def refresh_guesses(self):
else:
m.pop("guess_top_photo", None)
m.pop("guess_top_slug", None)
- jf.write_text(json.dumps(m, ensure_ascii=False))
+ # Atomic replace — a crashed mid-write would otherwise truncate the
+ # JSON (and the embedding inside it) for a silent loss on next load.
+ raw = json.dumps(m, ensure_ascii=False)
+ tmp = jf.with_suffix(".json.tmp")
+ tmp.write_text(raw)
+ os.replace(tmp, jf)
+
+ def request_refresh_guesses(self, delay: float = 0.4):
+ """Coalesce refresh_guesses from request handlers (delete / set-aside / dedupe).
+
+ Each refresh re-matches every unknown; firing it once per deleted photo
+ is O(N × unknowns) on the HTTP thread. A short debounce collapses a
+ cleanup burst into one background pass.
+ """
+ with self._refresh_sched_lock:
+ self._refresh_deadline = time.time() + delay
+ if self._refresh_thread is not None and self._refresh_thread.is_alive():
+ return
+ t = threading.Thread(target=self._refresh_guesses_worker, name="refresh-guesses",
+ daemon=True)
+ self._refresh_thread = t
+ t.start()
+
+ def _refresh_guesses_worker(self):
+ while True:
+ with self._refresh_sched_lock:
+ deadline = self._refresh_deadline
+ if deadline is None:
+ return
+ wait = deadline - time.time()
+ if wait > 0:
+ time.sleep(wait)
+ continue
+ with self._refresh_sched_lock:
+ if self._refresh_deadline is not None and time.time() < self._refresh_deadline:
+ continue
+ self._refresh_deadline = None
+ try:
+ self.refresh_guesses()
+ except Exception:
+ log.exception("background refresh_guesses failed")
+ with self._refresh_sched_lock:
+ if self._refresh_deadline is None:
+ self._refresh_thread = None
+ return
def discard_unknown(self, uid: str):
(self.unknown_dir / f"{uid}.json").unlink(missing_ok=True)
diff --git a/faceid-addon/app/webui.py b/faceid-addon/app/webui.py
index f476b93..5291d7d 100644
--- a/faceid-addon/app/webui.py
+++ b/faceid-addon/app/webui.py
@@ -77,7 +77,7 @@ def create_person(body: NameBody):
@app.delete("/api/persons/{slug}")
def delete_person(slug: str):
gallery.delete_person(slug)
- gallery.refresh_guesses()
+ gallery.request_refresh_guesses()
return {"ok": True}
class FavBody(BaseModel):
@@ -150,7 +150,7 @@ def set_aside_face(slug: str, fname: str, body: AsideBody):
if not gallery.set_aside(slug, fname, body.reason):
raise HTTPException(404, "unknown person or photo")
log.info("%s/%s set aside — %s", slug, fname, body.reason)
- gallery.refresh_guesses()
+ gallery.request_refresh_guesses()
return {"ok": True}
@app.post("/api/persons/{slug}/rename")
@@ -173,7 +173,7 @@ def set_favorite(slug: str, body: FavBody):
def restore_trimmed(slug: str, fname: str):
ok = gallery.restore_trimmed(slug, fname)
if ok:
- gallery.refresh_guesses()
+ gallery.request_refresh_guesses()
return {"ok": ok}
@app.delete("/api/persons/{slug}/trimmed/{fname}")
@@ -194,14 +194,14 @@ def deduplicate(body: dict = None):
pix = gallery.deduplicate_pixels_all(dry_run=dry)
emb = gallery.deduplicate_all(thr, dry_run=dry)
if not dry and (pix or emb):
- gallery.refresh_guesses()
+ gallery.request_refresh_guesses()
key = "would_remove" if dry else "moved"
return {key: pix + emb, "same_image": pix, "similar_face": emb, "threshold": thr}
@app.delete("/api/persons/{slug}/faces/{fname}")
def delete_face(slug: str, fname: str):
gallery.delete_face(slug, fname)
- gallery.refresh_guesses()
+ gallery.request_refresh_guesses()
return {"ok": True}
@app.post("/api/persons/{slug}/faces/{fname}/unassign")
From 5cd50b248e490cdcdb0db2999069f6f0dd5f9de0 Mon Sep 17 00:00:00 2001
From: skycryer
Date: Wed, 7 Oct 2026 11:21:16 +0200
Subject: [PATCH 6/8] Serialise the refresh pass and cap the debounce
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two findings from the review of the debounced refresh.
The temp file was named after its target, so every writer of the same
unknown shared one path — and nothing stopped a request handler's direct
refresh_guesses() from running while the background worker was in one. Two
passes then interleaved as 'A writes half, B replaces', publishing the
truncated file as the real metadata: exactly what the atomic replace is
there to prevent. The pass now runs under its own lock (not self._lock,
which match() already holds), and each write goes through
tempfile.mkstemp() so no two writers can collide. A failed write unlinks
its temp file instead of leaving it behind.
The debounce was trailing-only, so a steady stream closer than the delay —
bulk deletion, a dedupe cleanup loop — deferred the refresh for the whole
burst while the UI kept showing cues for photos that were already gone. The
deadline is now also capped at first_request + max_delay (2 s default).
Verified the other way round: with the shared temp name and the
trailing-only deadline restored, both new tests fail, five runs out of
five — the concurrency one on the FileNotFoundError from two threads
racing for the same temp path. 108 tests green with the fix.
---
app/gallery.py | 49 +++++++++++++++++++----
faceid-addon/CHANGELOG.md | 4 ++
faceid-addon/app/gallery.py | 49 +++++++++++++++++++----
tests/test_gallery_person_names.py | 64 ++++++++++++++++++++++++++++++
4 files changed, 152 insertions(+), 14 deletions(-)
diff --git a/app/gallery.py b/app/gallery.py
index 6aefcd9..0fe78ff 100644
--- a/app/gallery.py
+++ b/app/gallery.py
@@ -9,6 +9,7 @@
import logging
import re
import shutil
+import tempfile
import threading
import time
import unicodedata
@@ -68,7 +69,11 @@ def __init__(self, data_dir: Path, top_k: int = 3, max_per_person: int = 40):
self._lock = threading.Lock()
self._refresh_sched_lock = threading.Lock()
self._refresh_deadline = None
+ self._refresh_first_request = None
self._refresh_thread = None
+ # Eigenes Lock, nicht self._lock: refresh_guesses ruft match(), und match()
+ # nimmt self._lock — unter demselben Lock waere das ein Deadlock.
+ self._refresh_run_lock = threading.Lock()
self._cache = {} # slug -> {"name":..., "emb": np.ndarray, "files": [...]}
self._ign_emb = np.zeros((0, 512), dtype=np.float32)
self._ign_ids: list[str] = []
@@ -1040,7 +1045,17 @@ def assign_unknown(self, uid: str, slug: str):
return True
def refresh_guesses(self):
- """Verbleibende Unknowns gegen die aktuelle Galerie neu bewerten (nach Zuordnungen)."""
+ """Verbleibende Unknowns gegen die aktuelle Galerie neu bewerten (nach Zuordnungen).
+
+ Laeuft serialisiert: der Hintergrund-Worker und ein Request-Handler koennen
+ sonst gleichzeitig durchlaufen, und zwei Durchgaenge am selben Unknown
+ verschraenken sich zu "A schreibt halb, B ersetzt" — dann wird genau die
+ abgeschnittene Datei zur echten, die das atomare Ersetzen verhindern soll.
+ """
+ with self._refresh_run_lock:
+ self._refresh_guesses_once()
+
+ def _refresh_guesses_once(self):
for jf in self.unknown_dir.glob("*.json"):
try:
m = json.loads(jf.read_text())
@@ -1059,11 +1074,22 @@ def refresh_guesses(self):
# Atomic replace — a crashed mid-write would otherwise truncate the
# JSON (and the embedding inside it) for a silent loss on next load.
raw = json.dumps(m, ensure_ascii=False)
- tmp = jf.with_suffix(".json.tmp")
- tmp.write_text(raw)
- os.replace(tmp, jf)
-
- def request_refresh_guesses(self, delay: float = 0.4):
+ # Eindeutiger Name je Schreibvorgang: ein fester ".json.tmp" waere
+ # fuer alle Schreiber derselbe Pfad. Der Rest des Programms sieht ihn
+ # nie, weil hier nur "*.json" gelesen wird.
+ fd, tmp_name = tempfile.mkstemp(dir=self.unknown_dir,
+ prefix=f".{jf.stem}-", suffix=".tmp")
+ tmp = Path(tmp_name)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
+ fh.write(raw)
+ os.replace(tmp, jf)
+ except OSError:
+ # Nichts halb Geschriebenes zuruecklassen, auch nicht als Muell.
+ tmp.unlink(missing_ok=True)
+ raise
+
+ def request_refresh_guesses(self, delay: float = 0.4, max_delay: float = 2.0):
"""Coalesce refresh_guesses from request handlers (delete / set-aside / dedupe).
Each refresh re-matches every unknown; firing it once per deleted photo
@@ -1071,7 +1097,15 @@ def request_refresh_guesses(self, delay: float = 0.4):
cleanup burst into one background pass.
"""
with self._refresh_sched_lock:
- self._refresh_deadline = time.time() + delay
+ now = time.time()
+ if self._refresh_first_request is None:
+ self._refresh_first_request = now
+ # Obergrenze gegen den Dauerstrom: rein nachlaufend wuerde ein Burst
+ # (Mehrfach-Loeschen, Dedupe-Schleife) die Aktualisierung so lange
+ # schieben, wie er dauert — die Oberflaeche zeigt derweil Hinweise auf
+ # Fotos, die es nicht mehr gibt.
+ self._refresh_deadline = min(now + delay,
+ self._refresh_first_request + max_delay)
if self._refresh_thread is not None and self._refresh_thread.is_alive():
return
t = threading.Thread(target=self._refresh_guesses_worker, name="refresh-guesses",
@@ -1093,6 +1127,7 @@ def _refresh_guesses_worker(self):
if self._refresh_deadline is not None and time.time() < self._refresh_deadline:
continue
self._refresh_deadline = None
+ self._refresh_first_request = None
try:
self.refresh_guesses()
except Exception:
diff --git a/faceid-addon/CHANGELOG.md b/faceid-addon/CHANGELOG.md
index 8555a79..e1df89e 100644
--- a/faceid-addon/CHANGELOG.md
+++ b/faceid-addon/CHANGELOG.md
@@ -3,6 +3,10 @@
All notable changes to FaceID. The Home Assistant app shows this file in the
update dialog; standalone users can watch GitHub releases.
+## Unreleased
+
+- **Looks-like cues show the gallery face.** Unknown review now stores `guess_top_slug` + `guess_top_photo` (the reference that best explains a guess) and shows a thumbnail next to *looks like* / ★ Looks like headers; hover enlarges, click opens the existing full viewer. Keys are omitted when there is no photo; deleting or setting aside a reference photo refreshes dangling cues.
+
## 0.25.1 — 2026-10-06
- **A failed assignment no longer leaves an empty person behind.** Assigning faces created
diff --git a/faceid-addon/app/gallery.py b/faceid-addon/app/gallery.py
index 6aefcd9..0fe78ff 100644
--- a/faceid-addon/app/gallery.py
+++ b/faceid-addon/app/gallery.py
@@ -9,6 +9,7 @@
import logging
import re
import shutil
+import tempfile
import threading
import time
import unicodedata
@@ -68,7 +69,11 @@ def __init__(self, data_dir: Path, top_k: int = 3, max_per_person: int = 40):
self._lock = threading.Lock()
self._refresh_sched_lock = threading.Lock()
self._refresh_deadline = None
+ self._refresh_first_request = None
self._refresh_thread = None
+ # Eigenes Lock, nicht self._lock: refresh_guesses ruft match(), und match()
+ # nimmt self._lock — unter demselben Lock waere das ein Deadlock.
+ self._refresh_run_lock = threading.Lock()
self._cache = {} # slug -> {"name":..., "emb": np.ndarray, "files": [...]}
self._ign_emb = np.zeros((0, 512), dtype=np.float32)
self._ign_ids: list[str] = []
@@ -1040,7 +1045,17 @@ def assign_unknown(self, uid: str, slug: str):
return True
def refresh_guesses(self):
- """Verbleibende Unknowns gegen die aktuelle Galerie neu bewerten (nach Zuordnungen)."""
+ """Verbleibende Unknowns gegen die aktuelle Galerie neu bewerten (nach Zuordnungen).
+
+ Laeuft serialisiert: der Hintergrund-Worker und ein Request-Handler koennen
+ sonst gleichzeitig durchlaufen, und zwei Durchgaenge am selben Unknown
+ verschraenken sich zu "A schreibt halb, B ersetzt" — dann wird genau die
+ abgeschnittene Datei zur echten, die das atomare Ersetzen verhindern soll.
+ """
+ with self._refresh_run_lock:
+ self._refresh_guesses_once()
+
+ def _refresh_guesses_once(self):
for jf in self.unknown_dir.glob("*.json"):
try:
m = json.loads(jf.read_text())
@@ -1059,11 +1074,22 @@ def refresh_guesses(self):
# Atomic replace — a crashed mid-write would otherwise truncate the
# JSON (and the embedding inside it) for a silent loss on next load.
raw = json.dumps(m, ensure_ascii=False)
- tmp = jf.with_suffix(".json.tmp")
- tmp.write_text(raw)
- os.replace(tmp, jf)
-
- def request_refresh_guesses(self, delay: float = 0.4):
+ # Eindeutiger Name je Schreibvorgang: ein fester ".json.tmp" waere
+ # fuer alle Schreiber derselbe Pfad. Der Rest des Programms sieht ihn
+ # nie, weil hier nur "*.json" gelesen wird.
+ fd, tmp_name = tempfile.mkstemp(dir=self.unknown_dir,
+ prefix=f".{jf.stem}-", suffix=".tmp")
+ tmp = Path(tmp_name)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
+ fh.write(raw)
+ os.replace(tmp, jf)
+ except OSError:
+ # Nichts halb Geschriebenes zuruecklassen, auch nicht als Muell.
+ tmp.unlink(missing_ok=True)
+ raise
+
+ def request_refresh_guesses(self, delay: float = 0.4, max_delay: float = 2.0):
"""Coalesce refresh_guesses from request handlers (delete / set-aside / dedupe).
Each refresh re-matches every unknown; firing it once per deleted photo
@@ -1071,7 +1097,15 @@ def request_refresh_guesses(self, delay: float = 0.4):
cleanup burst into one background pass.
"""
with self._refresh_sched_lock:
- self._refresh_deadline = time.time() + delay
+ now = time.time()
+ if self._refresh_first_request is None:
+ self._refresh_first_request = now
+ # Obergrenze gegen den Dauerstrom: rein nachlaufend wuerde ein Burst
+ # (Mehrfach-Loeschen, Dedupe-Schleife) die Aktualisierung so lange
+ # schieben, wie er dauert — die Oberflaeche zeigt derweil Hinweise auf
+ # Fotos, die es nicht mehr gibt.
+ self._refresh_deadline = min(now + delay,
+ self._refresh_first_request + max_delay)
if self._refresh_thread is not None and self._refresh_thread.is_alive():
return
t = threading.Thread(target=self._refresh_guesses_worker, name="refresh-guesses",
@@ -1093,6 +1127,7 @@ def _refresh_guesses_worker(self):
if self._refresh_deadline is not None and time.time() < self._refresh_deadline:
continue
self._refresh_deadline = None
+ self._refresh_first_request = None
try:
self.refresh_guesses()
except Exception:
diff --git a/tests/test_gallery_person_names.py b/tests/test_gallery_person_names.py
index e9070c9..b5a6261 100644
--- a/tests/test_gallery_person_names.py
+++ b/tests/test_gallery_person_names.py
@@ -1,7 +1,12 @@
+import json
import tempfile
+import threading
+import time
import unittest
from pathlib import Path
+import numpy as np
+
from app.gallery import Gallery, slugify
@@ -45,3 +50,62 @@ def test_same_unicode_name_is_idempotent(self):
if __name__ == "__main__":
unittest.main()
+
+
+class RefreshGuessesConcurrencyTests(unittest.TestCase):
+ """Zwei gleichzeitige Durchlaeufe duerfen keine halbe Datei veroeffentlichen."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ d = Path(self.tmp.name)
+ (d / "persons").mkdir()
+ (d / "ignored").mkdir()
+ self.gal = Gallery(d, top_k=3, max_per_person=40)
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def _unknown(self, uid: str):
+ emb = [round(float(v), 6) for v in (np.ones(512, dtype=np.float32) / np.sqrt(512))]
+ (self.gal.unknown_dir / f"{uid}.json").write_text(
+ json.dumps({"ts": 1, "embedding": emb, "camera": "entrance"}))
+
+ def test_parallel_refreshes_never_publish_a_partial_file(self):
+ for i in range(12):
+ self._unknown(f"u{i}")
+ errors = []
+
+ def run():
+ try:
+ for _ in range(6):
+ self.gal.refresh_guesses()
+ except Exception as exc: # pragma: no cover - nur im Fehlerfall
+ errors.append(exc)
+
+ threads = [threading.Thread(target=run) for _ in range(4)]
+ [t.start() for t in threads]
+ [t.join() for t in threads]
+ self.assertEqual(errors, [])
+ for jf in self.gal.unknown_dir.glob("*.json"):
+ m = json.loads(jf.read_text()) # wirft, wenn abgeschnitten
+ self.assertEqual(len(m["embedding"]), 512,
+ f"{jf.name} hat sein Embedding verloren")
+
+ def test_no_temp_files_are_left_behind(self):
+ self._unknown("u1")
+ self.gal.refresh_guesses()
+ leftovers = [p.name for p in self.gal.unknown_dir.iterdir() if p.suffix == ".tmp"]
+ self.assertEqual(leftovers, [])
+
+ def test_a_burst_cannot_defer_the_refresh_forever(self):
+ # Rein nachlaufend wuerde jeder Aufruf die Frist neu setzen; die Obergrenze
+ # muss die Frist festnageln, sobald max_delay erreicht ist.
+ self._unknown("u1")
+ start = time.time()
+ for _ in range(50):
+ self.gal.request_refresh_guesses(delay=0.4, max_delay=0.3)
+ with self.gal._refresh_sched_lock:
+ deadline = self.gal._refresh_deadline
+ self.assertIsNotNone(deadline)
+ self.assertLessEqual(deadline - start, 0.35,
+ "die Obergrenze muss den Dauerstrom abschneiden")
From 9736b048ef45bcd5d77857ff327dad7b3e5bc687 Mon Sep 17 00:00:00 2001
From: skycryer
Date: Wed, 7 Oct 2026 11:26:48 +0200
Subject: [PATCH 7/8] Keep the file mode, and make the replace durable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two differences from the plain write_text() this replaced.
mkstemp() creates with 0600 and os.replace() carries that mode onto the
target, so every refresh would have narrowed each unknown's JSON to the
service user. The mode of the existing file is now copied onto the temp
file before the replace, falling back to 0644.
And the comment promised more than the code delivered: without
flush+fsync the rename can land ahead of the data, leaving a zero-length
file after a power cut — the silent loss the atomic replace is there to
prevent. The write is now fsynced, and the directory once per pass rather
than per file.
Measured before claiming it is cheap: 200 unknowns 43.4 ms with fsync
against 41.6 ms without (0.009 ms per file), 2000 unknowns 426 ms against
428 ms — inside the noise. The mode test fails with 384 != 420 when the
chmod is removed.
---
app/gallery.py | 33 ++++++++++++++++++++++++++++++
faceid-addon/app/gallery.py | 33 ++++++++++++++++++++++++++++++
tests/test_gallery_person_names.py | 11 ++++++++++
3 files changed, 77 insertions(+)
diff --git a/app/gallery.py b/app/gallery.py
index 0fe78ff..1813a05 100644
--- a/app/gallery.py
+++ b/app/gallery.py
@@ -9,6 +9,7 @@
import logging
import re
import shutil
+import stat as stat_module
import tempfile
import threading
import time
@@ -1056,6 +1057,7 @@ def refresh_guesses(self):
self._refresh_guesses_once()
def _refresh_guesses_once(self):
+ changed = False
for jf in self.unknown_dir.glob("*.json"):
try:
m = json.loads(jf.read_text())
@@ -1083,11 +1085,42 @@ def _refresh_guesses_once(self):
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(raw)
+ # Ohne flush+fsync darf das Dateisystem den Namenstausch vor die
+ # Daten ziehen: nach einem Stromausfall steht dann eine leere Datei
+ # da, wo vorher ein vollstaendiges Embedding war. Genau der stille
+ # Verlust, gegen den das Ersetzen hier antritt.
+ fh.flush()
+ os.fsync(fh.fileno())
+ # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die
+ # Datei waere danach nur noch fuer den Dienstnutzer lesbar. Den Modus
+ # des Ziels uebernehmen, sonst die uebliche Vorgabe.
+ try:
+ os.chmod(tmp, stat_module.S_IMODE(os.stat(jf).st_mode))
+ except OSError:
+ os.chmod(tmp, 0o644)
os.replace(tmp, jf)
+ changed = True
except OSError:
# Nichts halb Geschriebenes zuruecklassen, auch nicht als Muell.
tmp.unlink(missing_ok=True)
raise
+ if changed:
+ self._fsync_unknown_dir()
+
+ def _fsync_unknown_dir(self):
+ """Einmal je Durchlauf, nicht je Datei: erst damit ist der Namenstausch
+ selbst dauerhaft, und ein fsync je Unknown waere bei tausenden Eintraegen
+ der teuerste Teil des Durchlaufs."""
+ try:
+ fd = os.open(self.unknown_dir, os.O_RDONLY)
+ except OSError:
+ return
+ try:
+ os.fsync(fd)
+ except OSError:
+ pass
+ finally:
+ os.close(fd)
def request_refresh_guesses(self, delay: float = 0.4, max_delay: float = 2.0):
"""Coalesce refresh_guesses from request handlers (delete / set-aside / dedupe).
diff --git a/faceid-addon/app/gallery.py b/faceid-addon/app/gallery.py
index 0fe78ff..1813a05 100644
--- a/faceid-addon/app/gallery.py
+++ b/faceid-addon/app/gallery.py
@@ -9,6 +9,7 @@
import logging
import re
import shutil
+import stat as stat_module
import tempfile
import threading
import time
@@ -1056,6 +1057,7 @@ def refresh_guesses(self):
self._refresh_guesses_once()
def _refresh_guesses_once(self):
+ changed = False
for jf in self.unknown_dir.glob("*.json"):
try:
m = json.loads(jf.read_text())
@@ -1083,11 +1085,42 @@ def _refresh_guesses_once(self):
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(raw)
+ # Ohne flush+fsync darf das Dateisystem den Namenstausch vor die
+ # Daten ziehen: nach einem Stromausfall steht dann eine leere Datei
+ # da, wo vorher ein vollstaendiges Embedding war. Genau der stille
+ # Verlust, gegen den das Ersetzen hier antritt.
+ fh.flush()
+ os.fsync(fh.fileno())
+ # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die
+ # Datei waere danach nur noch fuer den Dienstnutzer lesbar. Den Modus
+ # des Ziels uebernehmen, sonst die uebliche Vorgabe.
+ try:
+ os.chmod(tmp, stat_module.S_IMODE(os.stat(jf).st_mode))
+ except OSError:
+ os.chmod(tmp, 0o644)
os.replace(tmp, jf)
+ changed = True
except OSError:
# Nichts halb Geschriebenes zuruecklassen, auch nicht als Muell.
tmp.unlink(missing_ok=True)
raise
+ if changed:
+ self._fsync_unknown_dir()
+
+ def _fsync_unknown_dir(self):
+ """Einmal je Durchlauf, nicht je Datei: erst damit ist der Namenstausch
+ selbst dauerhaft, und ein fsync je Unknown waere bei tausenden Eintraegen
+ der teuerste Teil des Durchlaufs."""
+ try:
+ fd = os.open(self.unknown_dir, os.O_RDONLY)
+ except OSError:
+ return
+ try:
+ os.fsync(fd)
+ except OSError:
+ pass
+ finally:
+ os.close(fd)
def request_refresh_guesses(self, delay: float = 0.4, max_delay: float = 2.0):
"""Coalesce refresh_guesses from request handlers (delete / set-aside / dedupe).
diff --git a/tests/test_gallery_person_names.py b/tests/test_gallery_person_names.py
index b5a6261..e77c0aa 100644
--- a/tests/test_gallery_person_names.py
+++ b/tests/test_gallery_person_names.py
@@ -109,3 +109,14 @@ def test_a_burst_cannot_defer_the_refresh_forever(self):
self.assertIsNotNone(deadline)
self.assertLessEqual(deadline - start, 0.35,
"die Obergrenze muss den Dauerstrom abschneiden")
+
+ def test_the_file_mode_survives_a_refresh(self):
+ # mkstemp legt mit 0600 an und os.replace nimmt den Modus mit — ohne
+ # Korrektur waere die Datei nach dem ersten Durchlauf nur noch fuer den
+ # Dienstnutzer lesbar.
+ self._unknown("u1")
+ jf = self.gal.unknown_dir / "u1.json"
+ import os as _os, stat as _stat
+ _os.chmod(jf, 0o644)
+ self.gal.refresh_guesses()
+ self.assertEqual(_stat.S_IMODE(_os.stat(jf).st_mode), 0o644)
From 8ccf4b42fa090466bab27eb960613fb9f6daab03 Mon Sep 17 00:00:00 2001
From: skycryer
Date: Wed, 7 Oct 2026 11:39:52 +0200
Subject: [PATCH 8/8] Do not resurrect an unknown deleted during the pass
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
os.replace() recreates its target unconditionally, so an unknown that was
assigned or discarded while the refresh was mid-pass came back as a JSON
file with stale metadata and no crop — and reappeared in the review queue.
The debounced background worker widened that window by design: the pass now
runs beside the handlers instead of inside one.
The target is stat'ed once (its mode is needed anyway) and a missing file
means skip rather than write, with a second existence check immediately
before the replace. The window shrinks from the whole write to two calls;
closing it fully would need the handlers to take the refresh lock, which is
a bigger change than this PR should carry.
Reverting the skip makes the new test fail with 'True is not false: ein
geloeschtes Unknown darf nicht zurueckkommen'. 110 tests green.
---
app/gallery.py | 23 +++++++++++++++++------
faceid-addon/app/gallery.py | 23 +++++++++++++++++------
tests/test_gallery_person_names.py | 24 ++++++++++++++++++++++++
3 files changed, 58 insertions(+), 12 deletions(-)
diff --git a/app/gallery.py b/app/gallery.py
index 1813a05..b767608 100644
--- a/app/gallery.py
+++ b/app/gallery.py
@@ -1091,13 +1091,24 @@ def _refresh_guesses_once(self):
# Verlust, gegen den das Ersetzen hier antritt.
fh.flush()
os.fsync(fh.fileno())
- # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die
- # Datei waere danach nur noch fuer den Dienstnutzer lesbar. Den Modus
- # des Ziels uebernehmen, sonst die uebliche Vorgabe.
+ # Ist das Ziel inzwischen weg, wurde dieses Unknown waehrend des
+ # Durchlaufs zugeordnet oder verworfen. os.replace() wuerde es
+ # wieder anlegen — ohne Bild, mit veralteten Daten, und es staende
+ # erneut in der Review-Queue. Der Hintergrundlauf macht dieses
+ # Fenster erst gross, weil er neben den Handlern laeuft.
try:
- os.chmod(tmp, stat_module.S_IMODE(os.stat(jf).st_mode))
- except OSError:
- os.chmod(tmp, 0o644)
+ mode = stat_module.S_IMODE(os.stat(jf).st_mode)
+ except FileNotFoundError:
+ tmp.unlink(missing_ok=True)
+ continue
+ # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die
+ # Datei waere danach nur noch fuer den Dienstnutzer lesbar.
+ os.chmod(tmp, mode)
+ # Kurz vor dem Tausch noch einmal nachsehen: das Fenster bleibt,
+ # aber es schrumpft von "ganzer Schreibvorgang" auf zwei Aufrufe.
+ if not jf.exists():
+ tmp.unlink(missing_ok=True)
+ continue
os.replace(tmp, jf)
changed = True
except OSError:
diff --git a/faceid-addon/app/gallery.py b/faceid-addon/app/gallery.py
index 1813a05..b767608 100644
--- a/faceid-addon/app/gallery.py
+++ b/faceid-addon/app/gallery.py
@@ -1091,13 +1091,24 @@ def _refresh_guesses_once(self):
# Verlust, gegen den das Ersetzen hier antritt.
fh.flush()
os.fsync(fh.fileno())
- # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die
- # Datei waere danach nur noch fuer den Dienstnutzer lesbar. Den Modus
- # des Ziels uebernehmen, sonst die uebliche Vorgabe.
+ # Ist das Ziel inzwischen weg, wurde dieses Unknown waehrend des
+ # Durchlaufs zugeordnet oder verworfen. os.replace() wuerde es
+ # wieder anlegen — ohne Bild, mit veralteten Daten, und es staende
+ # erneut in der Review-Queue. Der Hintergrundlauf macht dieses
+ # Fenster erst gross, weil er neben den Handlern laeuft.
try:
- os.chmod(tmp, stat_module.S_IMODE(os.stat(jf).st_mode))
- except OSError:
- os.chmod(tmp, 0o644)
+ mode = stat_module.S_IMODE(os.stat(jf).st_mode)
+ except FileNotFoundError:
+ tmp.unlink(missing_ok=True)
+ continue
+ # mkstemp legt mit 0600 an, und os.replace nimmt den Modus mit — die
+ # Datei waere danach nur noch fuer den Dienstnutzer lesbar.
+ os.chmod(tmp, mode)
+ # Kurz vor dem Tausch noch einmal nachsehen: das Fenster bleibt,
+ # aber es schrumpft von "ganzer Schreibvorgang" auf zwei Aufrufe.
+ if not jf.exists():
+ tmp.unlink(missing_ok=True)
+ continue
os.replace(tmp, jf)
changed = True
except OSError:
diff --git a/tests/test_gallery_person_names.py b/tests/test_gallery_person_names.py
index e77c0aa..11f7637 100644
--- a/tests/test_gallery_person_names.py
+++ b/tests/test_gallery_person_names.py
@@ -120,3 +120,27 @@ def test_the_file_mode_survives_a_refresh(self):
_os.chmod(jf, 0o644)
self.gal.refresh_guesses()
self.assertEqual(_stat.S_IMODE(_os.stat(jf).st_mode), 0o644)
+
+ def test_an_unknown_deleted_during_the_pass_is_not_resurrected(self):
+ # Der Hintergrundlauf laeuft neben den Handlern: wird ein Unknown waehrend
+ # des Durchlaufs zugeordnet oder verworfen, darf os.replace() es nicht wieder
+ # anlegen — es stuende sonst ohne Bild erneut in der Review-Queue.
+ self._unknown("u1")
+ jf = self.gal.unknown_dir / "u1.json"
+ (self.gal.unknown_dir / "u1.jpg").write_bytes(b"jpg")
+ real_match = self.gal.match
+
+ def match_and_delete(emb):
+ # Genau das, was discard_unknown()/assign_unknown() tun wuerden.
+ jf.unlink(missing_ok=True)
+ (self.gal.unknown_dir / "u1.jpg").unlink(missing_ok=True)
+ return real_match(emb)
+
+ self.gal.match = match_and_delete
+ try:
+ self.gal.refresh_guesses()
+ finally:
+ self.gal.match = real_match
+ self.assertFalse(jf.exists(), "ein geloeschtes Unknown darf nicht zurueckkommen")
+ self.assertEqual([p.name for p in self.gal.unknown_dir.iterdir()], [],
+ "und auch keine Temp-Datei hinterlassen")