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:
co-authored by
Claude Fable 5
parent
cf6dd5e134
commit
9610f3b774
@@ -811,24 +811,50 @@ def create_app(
|
|||||||
def api_job() -> dict:
|
def api_job() -> dict:
|
||||||
return jobs.snapshot()
|
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")
|
@app.post("/api/photos")
|
||||||
async def api_photos(files: list[UploadFile]) -> dict:
|
async def api_photos(files: list[UploadFile]) -> dict:
|
||||||
saved = []
|
saved = []
|
||||||
|
batch: set[str] = set()
|
||||||
cfg.photos_dir.mkdir(parents=True, exist_ok=True)
|
cfg.photos_dir.mkdir(parents=True, exist_ok=True)
|
||||||
for upload_file in files:
|
for upload_file in files:
|
||||||
name = Path(upload_file.filename or "").name # strips any path
|
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)'}")
|
raise HTTPException(400, f"not a photo: {name or '(unnamed)'}")
|
||||||
target = cfg.photos_dir / name
|
|
||||||
data = await upload_file.read()
|
data = await upload_file.read()
|
||||||
# a re-uploaded photo means "re-extract this one": the raw cache
|
target = cfg.photos_dir / name
|
||||||
# goes FIRST, so a crash can never leave the new photo paired
|
if Path(name).stem.casefold() in GENERIC_STEMS or name in batch:
|
||||||
# with the old photo's extraction
|
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"
|
stale = cfg.extract_raw_dir / f"{name}.json"
|
||||||
if stale.exists():
|
if stale.exists():
|
||||||
stale.unlink()
|
stale.unlink()
|
||||||
atomic_write_bytes(target, data)
|
atomic_write_bytes(target, data)
|
||||||
saved.append(name)
|
saved.append(name)
|
||||||
|
batch.add(name)
|
||||||
return {"saved": saved, "photos": len(photo_names())}
|
return {"saved": saved, "photos": len(photo_names())}
|
||||||
|
|
||||||
@app.get("/api/state")
|
@app.get("/api/state")
|
||||||
|
|||||||
@@ -1059,3 +1059,35 @@ def test_home_screen_icon_is_served_and_public(tmp_path):
|
|||||||
assert phone.get("/static/apple-touch-icon.png").status_code == 200
|
assert phone.get("/static/apple-touch-icon.png").status_code == 200
|
||||||
page = phone.get("/?k=sekret", follow_redirects=True)
|
page = phone.get("/?k=sekret", follow_redirects=True)
|
||||||
assert 'rel="apple-touch-icon"' in page.text
|
assert 'rel="apple-touch-icon"' in page.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_camera_names_never_overwrite(tmp_path):
|
||||||
|
web, cfg = make_client(tmp_path)
|
||||||
|
|
||||||
|
def send(*photos):
|
||||||
|
return web.post(
|
||||||
|
"/api/photos",
|
||||||
|
files=[("files", (name, data, "image/jpeg")) for name, data in photos],
|
||||||
|
)
|
||||||
|
|
||||||
|
# two camera captures in one batch, both "image.jpg" (the iOS shape)
|
||||||
|
res = send(("image.jpg", b"\xff\xd8first"), ("image.jpg", b"\xff\xd8second"))
|
||||||
|
assert res.status_code == 200
|
||||||
|
saved = res.json()["saved"]
|
||||||
|
assert len(saved) == len(set(saved)) == 2
|
||||||
|
assert all(n.startswith("shelf-") for n in saved)
|
||||||
|
on_disk = {p.name: p.read_bytes() for p in cfg.photos_dir.glob("shelf-*")}
|
||||||
|
assert set(on_disk.values()) == {b"\xff\xd8first", b"\xff\xd8second"}
|
||||||
|
|
||||||
|
# a third capture later gets its own name too
|
||||||
|
res2 = send(("image.jpg", b"\xff\xd8third"))
|
||||||
|
(third,) = res2.json()["saved"]
|
||||||
|
assert third.startswith("shelf-") and third not in saved
|
||||||
|
|
||||||
|
# a named photo keeps the deliberate replace-to-reshoot semantics
|
||||||
|
(cfg.extract_raw_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
(cfg.extract_raw_dir / "IMG_9999.jpeg.json").write_text("{}")
|
||||||
|
res3 = send(("IMG_9999.jpeg", b"\xff\xd8reshoot"))
|
||||||
|
assert res3.json()["saved"] == ["IMG_9999.jpeg"]
|
||||||
|
assert (cfg.photos_dir / "IMG_9999.jpeg").read_bytes() == b"\xff\xd8reshoot"
|
||||||
|
assert not (cfg.extract_raw_dir / "IMG_9999.jpeg.json").exists()
|
||||||
|
|||||||
Reference in New Issue
Block a user