Camera uploads stop overwriting each other

iOS names every camera capture "image.jpg"; the replace-to-reshoot
semantics (same name = re-extract this photo) then silently destroyed
the previous shot — which is how a shelf photo vanished today. Generic
capture names (image/photo/img/capture stems) now get minted unique
names (shelf-<timestamp>[-n]) server-side, colliding names within one
batch uniquify too, and an identical re-send of the same shot dedupes
to a no-op. Named photos (IMG_1234.jpeg) keep the deliberate reshoot
replacement flow. The upload feedback shows the minted names, so the
phone sees exactly what landed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-03 17:11:24 -04:00
co-authored by Claude Fable 5
parent cf6dd5e134
commit 9610f3b774
2 changed files with 63 additions and 5 deletions
+31 -5
View File
@@ -811,24 +811,50 @@ def create_app(
def api_job() -> dict:
return jobs.snapshot()
# camera captures arrive with generic names (iOS: every photo is
# "image.jpg") — treating those as the deliberate replace-to-reshoot
# flow silently overwrites the previous shot
GENERIC_STEMS = {"image", "photo", "img", "capture"}
def _minted_name(suffix: str, batch: set[str]) -> str:
stamp = time.strftime("%Y%m%d-%H%M%S")
for n in range(1000):
candidate = f"shelf-{stamp}{f'-{n}' if n else ''}{suffix}"
if candidate not in batch and not (cfg.photos_dir / candidate).exists():
return candidate
raise HTTPException(500, "couldn't find a free photo name")
@app.post("/api/photos")
async def api_photos(files: list[UploadFile]) -> dict:
saved = []
batch: set[str] = set()
cfg.photos_dir.mkdir(parents=True, exist_ok=True)
for upload_file in files:
name = Path(upload_file.filename or "").name # strips any path
if not name or Path(name).suffix.lower() not in PHOTO_SUFFIXES:
suffix = Path(name).suffix.lower()
if not name or suffix not in PHOTO_SUFFIXES:
raise HTTPException(400, f"not a photo: {name or '(unnamed)'}")
target = cfg.photos_dir / name
data = await upload_file.read()
# a re-uploaded photo means "re-extract this one": the raw cache
# goes FIRST, so a crash can never leave the new photo paired
# with the old photo's extraction
target = cfg.photos_dir / name
if Path(name).stem.casefold() in GENERIC_STEMS or name in batch:
if (
name not in batch
and target.exists()
and target.read_bytes() == data
):
saved.append(name) # the same shot, re-sent: no-op
continue
name = _minted_name(suffix, batch)
target = cfg.photos_dir / name
# a re-uploaded NAMED photo means "re-extract this one": the raw
# cache goes FIRST, so a crash can never leave the new photo
# paired with the old photo's extraction
stale = cfg.extract_raw_dir / f"{name}.json"
if stale.exists():
stale.unlink()
atomic_write_bytes(target, data)
saved.append(name)
batch.add(name)
return {"saved": saved, "photos": len(photo_names())}
@app.get("/api/state")