REST and WebSocket API for the Ansikten backend.
- Base URL:
http://127.0.0.1:5001(endpoint paths below include the full/api/v1/...prefix) - WebSocket:
ws://127.0.0.1:5001/ws/progress - Format: JSON
- API Version: v1
Check backend status and component readiness.
Response (all ready):
{
"status": "ok",
"service": "ansikten-backend",
"version": "1.5.0",
"components": {
"backend": { "state": "ready", "message": "Connected" },
"database": { "state": "ready", "message": "42 persons" },
"mlModels": { "state": "ready", "message": "Ready (2.3s)" }
}
}Response (starting up):
{
"status": "starting",
"service": "ansikten-backend",
"version": "1.5.0",
"components": {
"backend": { "state": "ready", "message": "Connected" },
"database": { "state": "loading", "message": "Läser in..." },
"mlModels": { "state": "pending", "message": "Waiting..." }
}
}Status values:
ok- All components readystarting- Still initializingdegraded- One or more components have errors
Detect faces in an image.
Request:
{
"image_path": "/path/to/image.NEF",
"force_reprocess": false
}Response:
{
"image_path": "/path/to/image.NEF",
"faces": [
{
"face_id": "face_0_abcd1234",
"bounding_box": { "x": 100, "y": 150, "width": 200, "height": 200 },
"confidence": 0.85,
"person_name": "Anna",
"is_confirmed": false,
"match_case": "name",
"ignore_distance": 0.42,
"ignore_confidence": 58,
"match_alternatives": [
{ "name": "Anna", "distance": 0.35, "confidence": 78, "is_ignored": false }
],
"encoding_hash": "sha1...",
"disambiguated": null
}
],
"processing_time_ms": 123.4,
"cached": false,
"file_hash": "sha1..."
}person_name is always the name of the first non-ignored entry in
match_alternatives — both are derived from the same ranked candidate list in a
single matching pass, so person_name can never point to a different person than
the recommended alternative. It is only set when match_case is name or
uncertain_name; for other cases it is null.
disambiguated is normally null. When the top-2 candidates are a registered
confirmed-distinct pair (see distinct-pairs) and nearly equidistant from the
probe (within twin_margin), a k-NN vote over both people's confirmed photos
re-decides the suggested name and this field records it:
{ "between": ["Wilmer", "Maximilian"], "chosen": "Wilmer", "method": "knn", "k": 5 }.
When set, the chosen name is also moved to the front of match_alternatives (so
the recommended option matches the decision); the remaining alternatives keep
their distance order. Tuned by twin_margin / twin_knn_k in config.json.
Get cropped face thumbnail.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
image_path |
string | Path to source image |
x |
int | Bounding box X |
y |
int | Bounding box Y |
width |
int | Bounding box width |
height |
int | Bounding box height |
size |
int | Output size (default: 150) |
Response: JPEG image binary
Confirm face identity and save to database.
Request:
{
"face_id": "face_0_abcd1234",
"person_name": "Anna",
"image_path": "/path/to/image.NEF",
"suggested_name": "Ann"
}Response:
{
"status": "success",
"person_name": "Anna",
"encodings_count": 12,
"enrolled": true,
"quality_note": null
}enrolled is false when the enrollment-quality gate (FIQA proxy) withheld
the encoding from the gallery because the crop was clearly low quality (e.g. low
detector confidence). The confirmation still succeeds — naming, file rename and
the attempt log are unaffected, and the match path is never touched — but the
encoding is not added to encodings.pkl. When gated, quality_note carries a
Swedish string explaining why (e.g. "Namnet sparades, men ansiktet lades inte
till i ansiktsbanken (låg detektionssäkerhet)."). Manual faces bypass the gate
(they carry no encoding). Configured under enrollment_quality — see
database.md. batch-confirm applies the same gate but its
response returns only aggregate counts (a gated face still counts as a
successful confirmation); the per-face enrolled/quality_note are logged at
INFO, not surfaced per item.
Mark face as ignored.
Request:
{
"face_id": "face_0_abcd1234",
"image_path": "/path/to/image.NEF"
}Response:
{
"status": "success",
"ignored_count": 42
}Mark file review as complete, log to attempt_stats.
Request:
{
"image_path": "/path/to/image.NEF",
"reviewed_faces": [
{
"face_index": 0,
"face_id": "face_0_abcd1234",
"encoding_hash": "sha1...",
"person_name": "Anna",
"is_ignored": false
},
{
"face_index": 1,
"face_id": "face_1_efgh5678",
"encoding_hash": "sha1...",
"person_name": null,
"is_ignored": true
}
],
"file_hash": "sha1..."
}Response:
{
"status": "success",
"message": "Review logged for 2 faces",
"labels_count": 2
}Confirm and/or ignore multiple faces in a single request with one database save.
More efficient than issuing individual confirm-identity / ignore-face calls
when saving all of an image's faces at once (the review flow submits here).
Request:
{
"confirmations": [
{
"face_id": "face_0_abcd1234",
"person_name": "Anna",
"image_path": "/path/to/image.NEF",
"suggested_name": "Ann"
}
],
"ignores": [
{ "face_id": "face_1_efgh5678", "image_path": "/path/to/image.NEF" }
]
}suggested_name is optional (records what the scanner proposed, for
hard-negative learning when the user corrected it). Both arrays default to
empty.
Response:
{
"status": "success",
"confirmed_count": 1,
"ignored_count": 1,
"errors": []
}errors collects per-item failures without aborting the rest of the batch.
Reload face database from disk.
Response:
{
"status": "success",
"people_count": 42,
"ignored_count": 12,
"cache_cleared": 10
}Get all people with encoding counts.
Response:
[
{ "name": "Anna", "encodingCount": 12 },
{ "name": "Bert", "encodingCount": 8 }
]Get list of person names (for autocomplete).
Response:
["Anna", "Bert", "Carl"]Get complete statistics summary.
Response:
{
"totalPeople": 42,
"totalEncodings": 156,
"totalProcessed": 1234,
"topFaces": [
{ "name": "Anna", "count": 12 }
],
"recentImages": [
{ "filename": "250101_120000.NEF", "timestamp": "2025-01-01T12:00:00" }
]
}Get attempt statistics table.
Get top faces by encoding count.
Get recently processed images.
Query: ?n=3 (default: 3)
Get recent log entries.
Query: ?n=3 (default: 3)
Get processed files list.
Query: ?n=200&source=cli
Get face-detection stats for specific files. Provide filepaths (preferred —
resolved by content hash, so the lookup survives renames) and/or filenames.
Request:
{
"filepaths": ["/path/to/250101_120000.NEF"],
"filenames": ["250101_120001.NEF"]
}Response: an object keyed by filename; only files with a match in the attempt log are included.
{
"250101_120000.NEF": { "face_count": 3, "persons": ["Anna", "Bert"] }
}Quick database statistics for UI.
Response:
{
"peopleCount": 42,
"encodingsCount": 156,
"processedCount": 1234,
"ignoredCount": 23
}Get current database state.
Find pairs of distinctly-named people whose faces look like the same person
(centroid cosine distance ≤ threshold), as merge candidates. People with no
usable encoding (e.g. only manual faces) are skipped. Pairs in the
confirmed-distinct registry (see below) are omitted.
Each pair also carries a head-to-head separability: a 1-NN leave-one-out
accuracy over the two people's confirmed embeddings (null when either has <2
usable encodings). ~1.0 means cleanly separable (different people who look alike,
e.g. twins) and sets likely_distinct: true; ~0.5 means indistinguishable
(likely the same person). likely_distinct pairs sort last.
Query params: threshold (float, default 0.35; lower = stricter).
Response:
{
"pairs": [
{ "name_a": "Elis", "name_b": "Elis Niemi", "distance": 0.18,
"count_a": 3, "count_b": 12, "separability": 0.5, "margin": -0.01,
"likely_distinct": false }
],
"threshold": 0.35,
"people_compared": 42
}Resolve a pair by calling merge-people (source_names: ["<drop>"], target_name: "<keep>").
People who look alike but are different (e.g. identical twins) can be marked so
the scanner stops suggesting them. Stored in distinct_pairs.json as sorted
name-pairs.
GET /api/v1/management/distinct-pairs→{ pairs: [{name_a, name_b}], count }. Pairs whose names no longer exist are pruned (self-healing).POST /api/v1/management/distinct-pair{name_a, name_b}→ add (400 if the names are equal/empty or either person doesn't currently exist).POST /api/v1/management/distinct-pair/remove{name_a, name_b}→ un-exclude.
Per-person count of redundant encodings within a person. threshold (query,
default 0.0, cosine distance): 0.0 counts only exact (byte-identical)
duplicates; higher also counts near-identical ones. Manual faces are never
counted. Lists only people with redundancy.
Response: { people: [{name, total, redundant, kept}], threshold, total_redundant }.
Remove redundant encodings from the named people, keeping one per group.
Request: { names: ["Anna", …], threshold: 0.0, dry_run: false }. With
dry_run: true nothing is removed (preview). Returns OperationResponse with
removed_per_person, total_removed, and new_state.
Rename person in database.
Request:
{
"oldName": "Anna",
"newName": "Anna S"
}Merge multiple people into target.
Request:
{
"sourceNames": ["Anna", "Anna S"],
"targetName": "Anna Svensson"
}Delete person from database.
Request:
{
"name": "Unknown"
}Move person's encodings to ignored list.
Request:
{
"name": "Unknown"
}Move encodings from ignored to person.
Request:
{
"name": "Anna",
"count": 5
}Undo processing for files matching pattern.
Request:
{
"pattern": "250101_*"
}Remove last X encodings from person.
Request:
{
"name": "Anna",
"count": 3
}Get last N processed files.
Query: ?n=10 (default: 10)
Get cache status.
Response:
{
"enabled": true,
"entryCount": 42,
"sizeBytes": 1234567
}Update cache settings.
Clear all cache entries.
Remove specific cache entry.
Compute SHA1 hash of file.
Request:
{
"file_path": "/path/to/image.NEF"
}Check what's cached for a file.
Convert NEF to JPG with caching.
Request:
{
"file_path": "/path/to/image.NEF",
"force": false
}Detect faces with caching.
Generate face thumbnails with caching.
Run all preprocessing steps.
Whole-frame overview thumbnail (JPEG) for the culling grid. Downscales a JPEG or
RAW file to size px on the longest edge. RAW files use the embedded preview
(fast) with a full-decode fallback; EXIF orientation is honoured.
Query parameters:
path— absolute path to the source imagesize— longest-edge size in px (default256, clamped to32–1024)
Response: image/jpeg bytes, with Cache-Control: public, max-age=604800
and X-Cache: HIT|MISS.
Cached under ~/.cache/ansikten/grid/, keyed on a cheap path + mtime + size
fingerprint (no full-file hashing), so an in-place re-export refreshes the
thumbnail automatically. Errors: 404 (missing file), 400 (path is a
directory).
Get rename configuration and presets.
Preview proposed file renames.
Request:
{
"files": ["/path/to/250101_120000.NEF"],
"options": {
"includeIgnored": false
}
}Execute file renames.
Request:
{
"renames": [
{
"oldPath": "/path/to/250101_120000.NEF",
"newPath": "/path/to/250101_120000_Anna.NEF"
}
]
}Get the stored free-text filename suffix for an image (for UI prefill).
Query: image_path — absolute path to the image.
Response:
{ "hash": "<sha1>", "suffix": "vinbar", "raw": "vinbär" }suffix is the normalized (filesystem-safe) form; raw is the stored text. Both are "" when no suffix is set.
Set or clear the free-text filename suffix for an image. The suffix is not a person name — it never reaches encodings.pkl, name autocomplete, or the person pipeline. It is stored keyed by content hash (manual_suffixes.json under BASE_DIR, stable across rename) and appended after any person names when the file is renamed. An empty / whitespace-only / path-only suffix clears the entry.
Request:
{ "image_path": "/path/to/260401_140101.jpg", "suffix": "vinbär" }Response: same shape as the GET (returns the normalized preview + stored raw).
Endpoints for filtering outlier encodings and maintaining database quality.
Preview what encodings would be removed.
Query Parameters:
| Parameter | Default | Description |
|---|---|---|
person |
null | Person name or * for all |
mode |
std |
Filter mode: std, cluster, mahalanobis, or shape |
std_threshold |
2.0 | Standard deviations for outlier detection |
cluster_dist |
0.35 | Max cosine distance from centroid |
cluster_min |
6 | Minimum cluster size |
mahalanobis_threshold |
3.0 | Mahalanobis distance threshold |
min_encodings |
8 | Skip filtering if fewer encodings |
Response:
{
"preview": [
{
"person": "Anna",
"total": 15,
"keep": 12,
"remove": 3,
"remove_indices": [2, 7, 14],
"reason": "std outlier",
"stats": {
"min_dist": 0.21,
"max_dist": 0.48,
"mean_dist": 0.31,
"std_dist": 0.08
}
}
],
"summary": {
"total_people": 42,
"affected_people": 5,
"total_remove": 12
}
}Apply filtering to remove outlier encodings.
Request:
{
"mode": "mahalanobis",
"persons": ["Anna", "Bert"],
"mahalanobis_threshold": 3.0,
"min_encodings": 8,
"dry_run": false
}Response:
{
"status": "ok",
"dry_run": false,
"removed": 5,
"by_person": { "Anna": 3, "Bert": 2 }
}Remove encodings with inconsistent shapes (keeps majority shape).
Request:
{
"persons": ["Anna"],
"dry_run": true
}Response:
{
"status": "ok",
"dry_run": true,
"total_removed": 2,
"repaired": [
{
"person": "Anna",
"removed": 2,
"total": 15,
"kept_shape": [512],
"removed_shapes": [[128], [128]]
}
]
}Remove ALL dlib (128-dim) encodings. dlib backend is deprecated.
Request:
{
"dry_run": true
}Response:
{
"status": "ok",
"dry_run": true,
"total_removed": 45,
"by_person": { "Anna": 10, "Bert": 8 },
"people_affected": 12
}Counts images per named player from filenames (no face recognition; the
filename format YYMMDD_HHMMSS[-N]_Name1,_Name2.ext is parsed). Backs the
Räkna spelare module.
Resolve a folder/glob/date selection and return per-player counts with median-baseline over/under-representation statistics.
Request:
{
"roots": ["/path/to/folder"],
"globs": ["~/Pictures/250601*.jpg"],
"extension_preset": "jpg",
"extensions": null,
"recursive": true,
"date_from": "2025-06-01",
"date_to": "2025-06-01",
"gap_minutes": 30,
"baseline": "median",
"min_images": 3,
"per_match": false,
"tranare": null,
"publik": null,
"grupp": null,
"spelare": null,
"session_tranare": null,
"session_publik": null,
"session_grupp": null
}extension_preset is one of jpg (jpg/jpeg), nef, raw, images, all
(case-insensitive); extensions overrides it with an explicit list. date_from/
date_to accept YYYY-MM-DD or YYMMDD and filter on the filename date.
At least one of roots/globs is required.
Response:
{
"total_images": 120,
"time_range": { "start": "2025-06-01T10:00:00", "end": "2025-06-01T12:30:00", "duration_minutes": 150.0 },
"baseline": 8.0,
"baseline_method": "median",
"players": [
{ "name": "Anna", "count": 10, "pct": 8.3, "delta_n": 2.0, "delta_pct": 25.0, "level": "high", "timestamps": ["2025-06-01T10:00:00"] }
],
"excluded": {
"tranare": [], "publik": [], "grupp": [{ "name": "Laget", "count": 3, "pct": 2.5 }],
"below_threshold": [{ "name": "Cesar", "count": 2, "pct": 1.7 }]
},
"matches": [],
"files_resolved": 123
}level is ok/warn/high (mirrors the CLI ±10/±20 % thresholds). matches
is populated only when per_match is true.
gap_minutes (match split), baseline (median/mean) and min_images map to
the CLI's --gap-minutes/--baseline/--min-images. tranare/publik/grupp
override the coach/audience/group exclusion lists for this request only (null
keeps the config/env defaults); the configured always-markers (default Laget/
FBK group, Klacken audience) are merged in regardless.
The culling module's live stats panel calls this endpoint too and now sends
baseline and min_images from the shared front-end counting settings (the
Baslinje select in its header), so its numbers match the Räkna spelare page
instead of falling back to the endpoint defaults. Omitting the fields still
yields the defaults (median / 3).
spelare/session_tranare/session_publik/session_grupp are per-request
pins that win over everything (config, env, overrides, even always-markers):
a pinned name is removed from all exclusion sets and lands only in its pinned
bucket. spelare pins to the players bucket and also bypasses min_images.
They back the GUI's session-only right-click moves (Räkna spelare + the culling
stats column) and are never persisted.
Return the currently resolved coach/audience/group exclusion lists (config/env + always-markers), so the GUI can pre-fill its editor.
Response:
{
"tranare": ["Coach"],
"publik": ["Klacken"],
"grupp": ["FBK", "Laget"],
"always": { "publik": ["Klacken"], "grupp": ["FBK", "Laget"] },
"config": { "tranare": ["Coach"], "publik": [], "grupp": [] },
"env_active": false,
"env_keys": []
}always is the configured always-excluded set (grupp/publik), defaulting to
the built-ins Laget/FBK and Klacken when the config doesn't override them —
it's now editable, not a fixed built-in. config mirrors the raw persisted
lists (no env values, no always-markers) — targeted saves (the GUI's "Gör publik
permanent") build on it so transient RAKNA_* env lists are never echoed into
the file. env_active/env_keys report which RAKNA_* env vars are shadowing
the config (so the GUI can warn that "save as default" won't take effect).
Persist exclusion lists to rakna_spelare.json as the new defaults (applies to
future counts and the CLI). Returns the same shape as the GET.
Request:
{
"tranare": ["Coach"],
"publik": ["Uncle"],
"grupp": [],
"always_grupp": ["Laget", "FBK", "Forward"],
"always_publik": ["Klacken"]
}tranare/publik are required (a partial/empty {} is rejected so it can't wipe
the config). grupp, always_grupp, always_publik are optional — null/omitted
leaves each unchanged. The always-markers are stored in their own keys
(always_grupp/always_publik) and stripped from the regular grupp/publik lists;
a present-but-empty always_* clears that always-set. Configure your own
always-excluded markers (e.g. a Forward group label) or drop a built-in here.
Note:
RAKNA_TRANARE/PUBLIK/GRUPPandRAKNA_ALWAYS_GRUPP/PUBLIKenv vars override the config file, so a saved default has no effect while the matching env var is set (surfaced viaenv_active/env_keys).
Backs the Gallra spelare module: list a player's JPEGs and soft-delete /
restore them via an app-managed trash (~/.local/share/faceid/trash/, manifest-
backed). Trashed files are automatically excluded from listing and counting.
List image files for the current filter. player is an exact parsed-name
filter; name_glob is a case-insensitive Finder-style basename pattern
(e.g. *ArvidW*) applied to the resolved files. extension_preset selects the
file types (jpg/nef/raw/...). Other fields match /players/count.
Unlike /players/count, this lists files without a _Name part too (e.g.
YYMMDD_HHMMSS.NEF from general culling before names are assigned); their date
is read from the YYMMDD_HHMMSS prefix and they contribute no players entries.
Response:
{
"files": [
{ "path": "/path/250601_100000_Anna.jpg", "basename": "250601_100000_Anna.jpg", "names": ["Anna"], "datetime": "2025-06-01T10:00:00", "mtime_ms": 1717236000000, "size": 4096 }
],
"players": ["Anna", "Bertil"]
}players lists every name present across the resolved files (computed before
the name_glob/player filter, so the dropdown stays complete).
Each file also carries an mtime_ms/size fingerprint (integer ms + bytes) so
the overview grid can cache-bust a thumbnail after an in-place re-export. Both
fields are omitted for a file that can't be stat'd (e.g. removed between the
scan and the response), so consumers must treat them as optional.
Move files (and their .xmp sidecars) to the app trash.
Request: { "paths": ["/path/250601_100000_Anna.jpg"] }
Response: { "trashed": [{ "id": "…", "original_path": "…", "basename": "…" }], "errors": [] }
Rename a single file (and its .xmp sidecars) to a new basename within the same
folder. The basename must be a bare filename (no path separators / ..); the
request is rejected if the target file — or any sidecar's target — already
exists (so a rename never half-applies). Case-only renames are supported.
Request: { "path": "/path/250601_100000_Anna,_Bo.jpg", "new_basename": "250601_100000_Anna.jpg" }
Response: { "path": "/path/250601_100000_Anna.jpg", "basename": "250601_100000_Anna.jpg" }
List the items currently in the trash: { "items": [{ "id", "original_path", "basename", "sidecars", "trashed_at" }] }.
Restore trashed items to their original location. Never overwrites: if the
original path is occupied, the file is restored as <stem>-restored<ext> (and
sidecars follow it).
Request: { "ids": ["…"] }
Response: { "restored": [{ "id": "…", "restored_path": "…" }], "errors": [] }
Permanently delete trashed items. { "ids": [...] } deletes those ids; omit
ids to empty the whole trash. Response: { "deleted": <count> }.
Transfer NEFs off a camera card and eject it. Backs the Importera module.
macOS-only (diskutil); the volume list is empty on other platforms.
List ejectable/external card volumes (never the internal disk).
Response:
{ "volumes": [ { "name": "NIKON Z 9", "mount": "/Volumes/NIKON Z 9", "nef_count": 312, "total_bytes": 12884901888, "ejectable": true } ] }Transfer the volume's NEFs (+ .xmp sidecars) to destination, then eject.
Skips files already present in the destination (never overwrites); ejects only
after a zero-error transfer. Emits import-progress over the WebSocket.
Request:
{ "volume_mount": "/Volumes/NIKON Z 9", "destination": "~/Pictures/nerladdat", "mode": "move", "eject": true }mode is move (default) or copy.
Response:
{ "transferred": ["…/DSC0001.NEF"], "skipped": [{"path":"…","reason":"finns redan i målmappen"}], "errors": [], "ejected": true, "eject_error": null, "total": 312 }eject_error is null on success, or a short reason string when eject was
attempted and failed (e.g. the card is held by another process).
A large import can take minutes; the client sends this request without a timeout. Should the HTTP response be lost, the terminal WebSocket event below carries the same summary so the UI can still complete.
WebSocket event import-progress:
- Per-file:
{ "phase": "transfer", "current": 5, "total": 312, "file": "DSC0005.NEF", "percent": 2 }. - Terminal (once, after the transfer loop):
{ "phase": "done", "transferred": 312, "skipped": 0, "errors": 0, "ejected": true, "eject_error": null, "destination": "/Users/…/nerladdat", "total": 312 }— counts (not the full path lists), the destination written to, and eject outcome.
EXIF-based NEF renaming (YYMMDD_HHMMSS.NEF) with preview/confirm. Backs the
Byt namn module. Requires exiftool.
Dry-run: resolve NEFs (folder/glob) and return the EXIF-derived rename mapping.
Request { roots, globs, recursive, include_named } (recursive and
include_named both default false).
Response:
{
"items": [ { "original_path": "…/DSC0001.NEF", "original": "DSC0001.NEF", "new_name": "250601_100000.NEF" } ],
"total_files": 12, "to_rename": 10, "already_named": 1, "named_affected": 0, "no_date": ["DSC9999.NEF"]
}no_date = files without a usable CreateDate (never renamed). Identical
timestamps are disambiguated with -NN.
Already-named files are protected by default. A file whose stem already
carries its EXIF timestamp — bare, or with a -N burst / _Name suffix
(260713_110145_Elis.NEF) — is treated as already named and excluded from the
plan, so a rename never strips a confirmed name. already_named counts the
protected (and bare-timestamp no-op) files. Set include_named: true to rename
them anyway (stripping the suffix); named_affected then reports how many
already-named files the plan would rename. The same protection applies to the
CLI (rename_nef.py, opt out with --include-named).
Rename the NEFs (+ .xmp sidecars) from EXIF. Reuses the preview's plan when
the request and every file's (mtime_ns, size) signature are unchanged
(skipping the EXIF re-read); any divergence — added/removed file, mtime/size
change — forces a full re-read. Concurrent executes are serialized with a
per-service lock. Two-pass via temp files; never overwrites — on a
target-name collision the original is restored and reported as skipped.
Both preview and execute emit rename-nef-progress over the WebSocket
during the EXIF phase:
{ "phase": "preview"|"execute", "current": 50, "total": 312, "percent": 16 }.
Response: { "renamed": [{"from":"DSC0001.NEF","to":"250601_100000.NEF"}], "skipped": [{"path":"…","reason":"…"}], "errors": [] }.
400 if no input or exiftool is missing.
Recovery: re-apply confirmed names after an accidental strip (or any external
rename). Request { roots, globs, recursive }. Each NEF is hashed (SHA1) and
matched against processed_files.jsonl; matches are renamed back to their
confirmed name (+ .xmp sidecar to the same stem). Never overwrites;
two-pass via temp files with restore-on-collision. Twins whose two hashes
recorded the same name are -N disambiguated, and each restored file's DB entry
is synced to its actual on-disk name. Emits restore-names-progress (same shape
as rename-nef-progress) during the hashing phase.
Preview response: { "items": [...], "total_files": N, "to_restore": N, "already_correct": N, "no_record": ["random.NEF"] }
(already_correct = stem already matches the DB name; no_record = no SHA1
match). Execute response: same shape as execute above.
Undo the last rename/move batch, replaying the append-only journal
(rename_journal.jsonl; see Database) in
reverse. The journal is the sole source of truth — undo replays exactly what a
batch recorded, never re-derives sidecars.
List recent journal batches, newest first. Query params limit (default 20)
and undoable_only (default true). With undoable_only=true the undoable
filter is applied before the limit, so a run of newer non-undoable batches
(imports, trash/restore) can't bury an older undoable rename past the cap (which
would look like "nothing to undo" in the GUI). Pass undoable_only=false to list
every batch.
Response: { "batches": [ { "batch_id": "9f3c…", "ts": "2026-07-14T08:15:00+00:00", "tool": "rename-nef", "op": "rename", "count": 12, "undoable": true } ] }.
undoable is true only when every row's op is rename. Import batches (move,
often cross-device — Path.rename would EXDEV; or copy → undo would delete)
and trash/restore batches (already undoable via the culling trash manifest)
are reported undoable: false.
Request { "batch_id": "9f3c…", "execute": false }. With execute: false
(default) this is a dry-run.
Preview response: { "batch_id", "tool", "op", "ts", "count", "to_revert": N, "to_skip": N, "items": [ { "from", "to", "from_name", "to_name", "status": "ok"|"skip", "reason" } ] }.
(tool/op/ts let the GUI label which action is being undone.)
The preview groups each journal row as one all-or-nothing unit (main + its
recorded sidecars) and reports the same decision the execute makes: a unit is
skipped if the main's file is gone, or any of the unit's original destinations is
occupied by an unrelated file (a destination taken only by another batch source
is free — that source moves away first, which is how burst-renumber chains
resolve). A missing sidecar is dropped from its unit (the main can still revert)
and reported skipped. So the preview never offers a partial revert the execute
then skips. Verification is path-state only — the journal carries no content
fingerprint, so undo confirms the move is safe to replay, not that the bytes are
unchanged.
Execute response: { "batch_id", "reverted": N, "skipped": N, "errors": N, "results": [ { "path", "status": "reverted"|"skipped"|"error", "reason" } ] }.
Reversals run through the shared two-pass mover (never-overwrite; within-batch
chains resolve), and the face DB is then repaired the same way the forward
face-rename does (RenameService._update_database_paths) with the reversed
mapping — both known_faces[*].file and processed_files[*].name are repointed
off the renamed name back onto the original (a no-op for names not in the DB, so
it runs for every tool's batch). The undo is itself journaled as a fresh undo
batch, so it is redoable. Only one undo runs at a time (serialized).
404 if the batch id is unknown; 400 if the batch is not undoable.
Real-time progress updates during processing.
Events:
| Event | Data | Description |
|---|---|---|
connected |
{ clientId } |
Connection established |
progress |
{ current, total, file } |
Processing progress |
face-detected |
{ file, faces } |
Face detected |
complete |
{ filesProcessed } |
Batch complete |
error |
{ message } |
Processing error |
import-progress |
{ phase: "transfer", current, total, file, percent } / { phase: "done", transferred, skipped, errors, ejected, eject_error, destination, total } |
Card import per-file progress + terminal summary |
rename-nef-progress |
{ phase, current, total, percent } |
EXIF read progress during NEF rename preview/execute |
restore-names-progress |
{ phase, current, total, percent } |
SHA1 hashing progress during restore-names preview/execute |
Example client:
const ws = new WebSocket('ws://127.0.0.1:5001/ws/progress');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'progress':
console.log(`${data.current}/${data.total}`);
break;
case 'face-detected':
console.log(`Found ${data.faces.length} faces`);
break;
}
};All endpoints return errors in this format:
{
"detail": "Error message",
"status": "error"
}Common HTTP status codes:
400- Bad request (invalid parameters)404- Not found (file or person doesn't exist)500- Internal server error