Photo versions: link AI restorations/edits to their original (#191) #193
No files matched your search
@@ -77,6 +77,11 @@ cargo run --bin cleanup_files -- --base-path /path/to/media --database-url ./dat
|
||||
|
||||
# Create a login user (no HTTP registration route; idempotent)
|
||||
cargo run --bin create_user -- <username> <password>
|
||||
|
||||
# Link photo versions ingested before linking existed. Dry run by default
|
||||
# (prints a TSV); --apply snapshots the DB first and writes the exact links,
|
||||
# --reviewed <tsv> adds the heuristic rows a human kept. See "Photo versions".
|
||||
cargo run --bin link_versions
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
@@ -351,6 +356,74 @@ the new hash, and the two histories cleanly split. Apollo's
|
||||
`derived.db` cache will need an invalidation hook for the changed
|
||||
hash — design it alongside Branch D.
|
||||
|
||||
### Photo versions
|
||||
|
||||
A **version** is a non-destructive restoration, upscale or edit of a photo that
|
||||
an external process (the ComfyUI node in `comfyui-imageapi-versions`) writes next
|
||||
to the original, as `<dir>/aiedits/<original name>.<kind>[.<n>].png`. ImageApi
|
||||
only detects it (library roots stay read-only) and shows original + versions as
|
||||
**one photo**; a *global* choice (not per user) says which file is displayed.
|
||||
Code: `src/database/version_dao.rs` (tables, DAO, SQL predicates),
|
||||
`src/versions/` (linking, endpoints, `xmp.rs` PNG chunk reader, `backfill.rs`
|
||||
for the `link_versions` binary).
|
||||
|
||||
- **The link is the original's `content_hash`** (`photo_versions.version_hash ->
|
||||
original_hash`, plus `kind`, `source` and a `(library_id, rel_path)` back-ref),
|
||||
so it survives renames, moves and the archive handoff. A file is a version of at
|
||||
most one original; chains flatten to the root. Reference photos (extra context fed
|
||||
to the model) go to `photo_version_references` as provenance only and are never
|
||||
hidden or grouped. `photo_version_display(original_hash -> display_hash)` is the
|
||||
global choice; no row = the original, and a display file with no live row reads as
|
||||
the original.
|
||||
- **Linking at ingest** (`watcher::process_new_files`, before any derived work):
|
||||
(1) a PNG's XMP `xmpMM:DerivedFrom/stRef:documentID = blake3:<hex>` (iTXt chunk
|
||||
`XML:com.adobe.xmp`, read by hand, no decode); (2) the file-name convention next
|
||||
to an indexed original in the same library (same dir, or the parent of
|
||||
`aiedits/`). Name links are retried every tick (`sweep_name_links`), so an
|
||||
original that wasn't hashed yet, or arrives later, still links. Only *new* rows
|
||||
are examined, so files ingested before this existed need the `link_versions`
|
||||
binary once.
|
||||
- **Date/GPS/camera copy**: a version row takes its original's date triple, GPS and
|
||||
camera fields with `date_taken_source = 'version_of'`, re-copied each tick
|
||||
(`sync_version_metadata`) when the original changes (manual override, re-resolve).
|
||||
Only a *converted* original (`date_utc` set) is copied, so the legacy-convention
|
||||
date drains never fight the copy. Memories therefore put a standalone version on
|
||||
the original's day.
|
||||
- **Hiding** uses the same reachability rule as `duplicate_of_hash`, with one
|
||||
addition: a version row is hidden only while its original is *shown in the
|
||||
current view*: a row exists there and was not demoted by duplicates resolve
|
||||
(`visible_version_clause`; unscoped = any library). A version
|
||||
whose original was deleted, demoted, or lives outside the requested library, shows as a
|
||||
normal photo, so nothing disappears silently. Applied in `/photos` (SQL for the
|
||||
recursive listing, `hidden_version_rows` for the folder walk and tag branch),
|
||||
`/memories` and reels, `/photos/exif`, `/photos/gps-summary`, unified search, the
|
||||
camera counts and the media gauges; `include_versions=true` lists them.
|
||||
- **Display file** is fetched through the original's library, so the display
|
||||
choice only counts when the chosen version has a row there (else the original
|
||||
shows; `PUT /image/versions/display` answers 400). Recording a link also drops
|
||||
the version's `face_detections`, CLIP columns and `clip_failures`.
|
||||
- **Tags and favorites** written or read on a version path bind to the original
|
||||
(`canonical_rel_path` in the tag/favorite DAOs, `get_rel_paths_sharing_content`
|
||||
for reads). Insight generation on a version path runs against the original. A
|
||||
*manual date* set on a version path is overwritten by the copy above: send the
|
||||
original's path.
|
||||
- **Excluded from derived work** (`NOT_A_VERSION_CLAUSE`): face backlog and ingest
|
||||
candidates, face stats (numerator and denominator), CLIP backlog and index, and
|
||||
exact/perceptual duplicate grouping. Thumbnails and previews *are* generated.
|
||||
Linking invalidates the duplicate caches.
|
||||
- **Lifecycle**: version file deleted -> the missing-file scan retires its row and
|
||||
the link plus any display choice naming it become orphan-GC candidates (two-tick,
|
||||
all-libraries-online), reads fall back to the original meanwhile. Original
|
||||
deleted -> versions surface as standalone photos and the links are kept (they
|
||||
revive if the original returns). The back-ref refresh repoints
|
||||
`photo_versions.(library_id, rel_path)` after a move.
|
||||
- **`link_versions`** (`src/bin/link_versions.rs`): dry run by default, prints a
|
||||
TSV. `exact` rows (XMP, file name) are what ingest would have linked and apply
|
||||
with `--apply`; `review` rows (ComfyUI prompt `LoadImage` input, Upscayl-style
|
||||
names, nearest pHash) apply only from a reviewed TSV (`--reviewed`), each checked
|
||||
against the current hash; `unresolved` rows are listed, never guessed. `--apply`
|
||||
takes a `VACUUM INTO` snapshot first.
|
||||
|
||||
### File Processing Pipeline
|
||||
|
||||
**Thumbnail Generation:**
|
||||
@@ -554,6 +627,16 @@ POST /image/tags (add tag to file)
|
||||
DELETE /image/tags (remove tag from file)
|
||||
POST /image/tags/batch (bulk tag updates)
|
||||
|
||||
// Photo versions (#191): AI restorations / upscales / edits shown as ONE photo
|
||||
GET /image/versions?path=...&library=... (path of the original or any version ->
|
||||
{ original:{path,library_id,hash}, versions:[{path,
|
||||
library_id,hash,kind,source,created_at,references,
|
||||
width,height}], display_hash })
|
||||
PUT /image/versions/display (body { path, display_hash|null, library? }; global choice)
|
||||
// /photos hides versions behind their original (include_versions=true lists them) and adds
|
||||
// parallel arrays photo_display_paths / photo_version_counts; memories items, search hits and
|
||||
// /photos/exif rows carry display_path / version_count. All skipped when there are none.
|
||||
|
||||
// Memories (week-based grouping)
|
||||
GET /memories?path=...&recursive=true
|
||||
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ end-to-end and agent-driven UI testing of the SynologyFileViewer app.
|
||||
dev/e2e/e2e.sh up # build + fixtures + reset + start; blocks until ingest is done
|
||||
dev/e2e/e2e.sh reset # back to the pristine baseline (after tests mutate things)
|
||||
dev/e2e/e2e.sh stop
|
||||
dev/e2e/e2e.sh check # every fixture's wall_clock + true_instant vs the manifest, and /photos/exif range edges
|
||||
dev/e2e/e2e.sh check # every fixture's wall_clock + true_instant vs the manifest, /photos/exif range edges, photo versions
|
||||
```
|
||||
|
||||
Log in from the app with **server** `http://10.0.2.2:8090` (Android
|
||||
@@ -56,6 +56,7 @@ way phones write containers (that wall, as a UTC `creation_time`).
|
||||
| `private` | `EXCLUDED_DIRS` — never indexed |
|
||||
| `Duplicates` | byte-identical copy of `2021/Paris Trip/IMG_1000.jpg` |
|
||||
| `Empty Album` | folder with no media |
|
||||
| `Versions/*` | photo versions (#191): an XMP-linked restoration (renamed file), a name-linked upscale (no XMP) and an edit with a reference photo, each under `aiedits/`; the manifest's `versions` block lists them. `e2e.sh check` verifies hiding, counts, display choice and the endpoints |
|
||||
|
||||
"On This Day" is relative to the generation date — run
|
||||
`e2e.sh fixtures --force && e2e.sh reset` on a later day to keep memories
|
||||
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Photo-version checks (ImageApi #191) against the e2e server.
|
||||
|
||||
Every `versions` entry in the manifest (an XMP-linked version, a name-linked
|
||||
one and one with a reference photo) must be:
|
||||
|
||||
- linked to its original with the expected kind, source-agnostic of path, and
|
||||
the reference photo recorded as provenance only;
|
||||
- hidden from `/photos` (recursive and the folder walk), `/photos/exif` and
|
||||
`/photos/gps-summary`, but listed with `include_versions=true`;
|
||||
- reflected on the original's row by `photo_version_counts`, and by
|
||||
`photo_display_paths` once a display version is chosen;
|
||||
- served on its own at `/image?path=<version>`.
|
||||
|
||||
The display choice is put back afterwards. Usage: check_versions.py <manifest.json>
|
||||
(run via `e2e.sh check`).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
E2E = Path(__file__).with_name("e2e.sh")
|
||||
|
||||
|
||||
def api(method: str, path: str, body: dict | None = None):
|
||||
cmd = [str(E2E), "api", method, path] + ([json.dumps(body)] if body is not None else [])
|
||||
out = subprocess.check_output(cmd)
|
||||
return json.loads(out) if out.strip() else None
|
||||
|
||||
|
||||
def q(path: str) -> str:
|
||||
return urllib.parse.quote(path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
manifest = json.load(open(sys.argv[1]))
|
||||
versions = manifest.get("versions", [])
|
||||
if not versions:
|
||||
print("manifest has no versions; regenerate the fixtures (e2e.sh fixtures --force)")
|
||||
return 1
|
||||
failures: list[str] = []
|
||||
|
||||
def check(ok: bool, what: str) -> None:
|
||||
if not ok:
|
||||
failures.append(what)
|
||||
|
||||
listing = api("GET", "photos?path=&recursive=true&sort=nameasc")
|
||||
full = api("GET", "photos?path=&recursive=true&sort=nameasc&include_versions=true")
|
||||
exif = {e["file_path"] for e in api("GET", f"photos/exif?date_from=0&date_to={2**62}")["photos"]}
|
||||
gps = {g["path"] for g in api("GET", "photos/gps-summary?path=")["photos"]}
|
||||
|
||||
for v in versions:
|
||||
orig, ver = v["original"], v["version"]
|
||||
name = f"{ver} ({v['source']})"
|
||||
check(orig in listing["photos"], f"{orig}: original missing from /photos")
|
||||
check(ver not in listing["photos"], f"{name}: version leaks into /photos")
|
||||
check(ver in full["photos"], f"{name}: include_versions=true must list it")
|
||||
check(ver not in exif, f"{name}: version leaks into /photos/exif")
|
||||
check(ver not in gps, f"{name}: version leaks into /photos/gps-summary")
|
||||
folder = ver.rsplit("/", 1)[0]
|
||||
walk = api("GET", f"photos?path={q(folder)}")
|
||||
check(ver not in walk["photos"], f"{name}: version leaks into the folder walk")
|
||||
for ref in v["references"]:
|
||||
check(ref in listing["photos"], f"{ref}: reference photo must stay visible")
|
||||
|
||||
# Counts ride along the original's row.
|
||||
idx = listing["photos"].index(orig) if orig in listing["photos"] else -1
|
||||
counts = listing.get("photo_version_counts", [])
|
||||
check(idx >= 0 and idx < len(counts) and counts[idx] >= 1, f"{orig}: photo_version_counts")
|
||||
|
||||
# The set, asked from either side.
|
||||
for asked in (orig, ver):
|
||||
info = api("GET", f"image/versions?path={q(asked)}")
|
||||
check(info["original"]["path"] == orig, f"{asked}: original.path")
|
||||
mine = [x for x in info["versions"] if x["path"] == ver]
|
||||
check(len(mine) == 1, f"{asked}: version {ver} not listed")
|
||||
if mine:
|
||||
check(mine[0]["kind"] == v["kind"], f"{ver}: kind {mine[0]['kind']} != {v['kind']}")
|
||||
check(len(mine[0]["references"]) == len(v["references"]), f"{ver}: reference count")
|
||||
for want, got in zip(v["references"], mine[0]["references"]):
|
||||
check(got.get("path") == want, f"{ver}: reference path {got.get('path')} != {want}")
|
||||
check(info["display_hash"] is None, f"{asked}: display_hash should start unset")
|
||||
|
||||
# Display choice: set from the version's own path, seen in /photos, cleared.
|
||||
info = api("GET", f"image/versions?path={q(orig)}")
|
||||
h = next(x["hash"] for x in info["versions"] if x["path"] == ver)
|
||||
api("PUT", "image/versions/display", {"path": ver, "display_hash": h})
|
||||
try:
|
||||
after = api("GET", "photos?path=&recursive=true&sort=nameasc")
|
||||
i = after["photos"].index(orig)
|
||||
shown = after.get("photo_display_paths", [])
|
||||
check(i < len(shown) and shown[i] == ver, f"{orig}: photo_display_paths after choosing {ver}")
|
||||
finally:
|
||||
api("PUT", "image/versions/display", {"path": orig, "display_hash": None})
|
||||
|
||||
# A version still downloads on its own.
|
||||
data = subprocess.run([str(E2E), "api", "GET", f"image?path={q(ver)}"],
|
||||
capture_output=True)
|
||||
check(data.returncode == 0 and len(data.stdout) > 1000, f"{ver}: /image?path= did not serve the file")
|
||||
|
||||
if failures:
|
||||
print("\n".join(f"FAIL {f}" for f in failures))
|
||||
print(f"{len(versions)} version fixtures checked, {len(failures)} failure(s)")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("E2E_TOKEN", subprocess.check_output([str(E2E), "token"], text=True).strip())
|
||||
sys.exit(main())
|
||||
+3
-2
@@ -150,7 +150,7 @@ api() { # api METHOD PATH [JSON]; reuses $E2E_TOKEN when set (login is a bcrypt
|
||||
}
|
||||
|
||||
indexed_count() {
|
||||
api GET "photos?path=&recursive=true" | python3 -c \
|
||||
api GET "photos?path=&recursive=true&include_versions=true" | python3 -c \
|
||||
'import json,sys; print(len(json.load(sys.stdin).get("photos", [])))' 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
@@ -273,7 +273,8 @@ case "${1:-help}" in
|
||||
restart) cmd_stop; cmd_start ;;
|
||||
status) cmd_status ;;
|
||||
token) login ;;
|
||||
check) E2E_TOKEN="$(login)" python3 "$HERE/check_wall_clock.py" "$STATE/manifest.json" ;;
|
||||
check) E2E_TOKEN="$(login)" python3 "$HERE/check_wall_clock.py" "$STATE/manifest.json" \
|
||||
&& E2E_TOKEN="$(login)" python3 "$HERE/check_versions.py" "$STATE/manifest.json" ;;
|
||||
api) shift; api "$@" ;;
|
||||
logs) tail -f "$LOG" ;;
|
||||
info) cmd_info ;;
|
||||
|
||||
+198
-3
@@ -24,8 +24,10 @@ import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import zlib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -115,6 +117,14 @@ class Item:
|
||||
tags: list[str] = field(default_factory=list)
|
||||
scenario: str = ""
|
||||
copy_of: str | None = None # byte-identical duplicate of another item
|
||||
# Photo versions (ImageApi #191): this PNG is a restoration / upscale / edit
|
||||
# of another item. `xmp` embeds the producer's XMP link (DerivedFrom = the
|
||||
# original's blake3, Ingredients = `references`), as the ComfyUI node does;
|
||||
# without it only the `<name>.<kind>.png` file-name convention links it.
|
||||
version_of: str | None = None
|
||||
version_kind: str = "restored"
|
||||
xmp: bool = False
|
||||
references: list[str] = field(default_factory=list) # item paths (provenance only)
|
||||
|
||||
def label_date(self) -> str:
|
||||
if self.taken:
|
||||
@@ -215,6 +225,45 @@ def build_items(today: dt.date) -> list[Item]:
|
||||
"Duplicates/IMG_1000 copy.jpg", "image", copy_of="2021/Paris Trip/IMG_1000.jpg",
|
||||
scenario="exact duplicate of 2021/Paris Trip/IMG_1000.jpg",
|
||||
))
|
||||
|
||||
# Photo versions (#191). Originals are dated, GPS-tagged JPEGs so the
|
||||
# version's copied date / GPS is checkable; every case lives in its own
|
||||
# folder with the producer's `aiedits/` subfolder.
|
||||
canon_v = ("Canon", "Canon EOS R5")
|
||||
for folder, stem, when in (
|
||||
("Xmp Linked", "scan_0042", dt.datetime(2004, 6, 12, 14, 30)),
|
||||
("Name Linked", "family_shot", dt.datetime(2008, 12, 24, 9, 15)),
|
||||
("Reference Photo", "subject", dt.datetime(2010, 3, 7, 16, 45)),
|
||||
):
|
||||
items.append(Item(
|
||||
f"Versions/{folder}/{stem}.jpg", "image", taken=when, gps="paris",
|
||||
camera=canon_v, tags=["versions"], scenario=f"versions: original ({folder})",
|
||||
))
|
||||
# Linked by the XMP chunk alone: the name says nothing about the original.
|
||||
items.append(Item(
|
||||
"Versions/Xmp Linked/aiedits/enhanced_final.png", "image", version_of=
|
||||
"Versions/Xmp Linked/scan_0042.jpg", version_kind="restored", xmp=True,
|
||||
scenario="version linked by XMP only (renamed file)",
|
||||
))
|
||||
# Linked by the file-name convention alone: no XMP in the file.
|
||||
items.append(Item(
|
||||
"Versions/Name Linked/aiedits/family_shot.jpg.upscale.png", "image", version_of=
|
||||
"Versions/Name Linked/family_shot.jpg", version_kind="upscale",
|
||||
scenario="version linked by file name only (no XMP)",
|
||||
))
|
||||
# An edit that also fed a second photo to the model: the other photo is a
|
||||
# reference (provenance), never hidden or grouped.
|
||||
items.append(Item(
|
||||
"Versions/Reference Photo/other_view.jpg", "image",
|
||||
taken=dt.datetime(2010, 3, 7, 16, 50), camera=canon_v,
|
||||
scenario="reference photo of a version: stays an ordinary photo",
|
||||
))
|
||||
items.append(Item(
|
||||
"Versions/Reference Photo/aiedits/subject.jpg.edit.png", "image", version_of=
|
||||
"Versions/Reference Photo/subject.jpg", version_kind="edit", xmp=True,
|
||||
references=["Versions/Reference Photo/other_view.jpg"],
|
||||
scenario="version with a reference photo (XMP Ingredients)",
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
@@ -250,6 +299,139 @@ def _same_day(today: dt.date, year: int, hour: int) -> dt.datetime:
|
||||
return dt.datetime(year, today.month, 28, hour, 0)
|
||||
|
||||
|
||||
# ── photo versions (#191) ───────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
import blake3 as _blake3
|
||||
|
||||
def blake3_hex(data: bytes) -> str:
|
||||
return _blake3.blake3(data).hexdigest()
|
||||
except ImportError: # keep the generator dependency-free: reference BLAKE3
|
||||
_IV = [0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
|
||||
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19]
|
||||
_PERM = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8]
|
||||
_CHUNK_START, _CHUNK_END, _PARENT, _ROOT = 1, 2, 4, 8
|
||||
_M = 0xFFFFFFFF
|
||||
|
||||
|
||||
def _compress(cv, block, counter, block_len, flags):
|
||||
v = list(cv) + _IV[:4] + [counter & _M, counter >> 32, block_len, flags]
|
||||
m = list(block)
|
||||
|
||||
def g(a, b, c, d, mx, my):
|
||||
v[a] = (v[a] + v[b] + mx) & _M
|
||||
v[d] = ((v[d] ^ v[a]) >> 16 | (v[d] ^ v[a]) << 16) & _M
|
||||
v[c] = (v[c] + v[d]) & _M
|
||||
v[b] = ((v[b] ^ v[c]) >> 12 | (v[b] ^ v[c]) << 20) & _M
|
||||
v[a] = (v[a] + v[b] + my) & _M
|
||||
v[d] = ((v[d] ^ v[a]) >> 8 | (v[d] ^ v[a]) << 24) & _M
|
||||
v[c] = (v[c] + v[d]) & _M
|
||||
v[b] = ((v[b] ^ v[c]) >> 7 | (v[b] ^ v[c]) << 25) & _M
|
||||
|
||||
for r in range(7):
|
||||
g(0, 4, 8, 12, m[0], m[1]); g(1, 5, 9, 13, m[2], m[3])
|
||||
g(2, 6, 10, 14, m[4], m[5]); g(3, 7, 11, 15, m[6], m[7])
|
||||
g(0, 5, 10, 15, m[8], m[9]); g(1, 6, 11, 12, m[10], m[11])
|
||||
g(2, 7, 8, 13, m[12], m[13]); g(3, 4, 9, 14, m[14], m[15])
|
||||
if r < 6:
|
||||
m = [m[i] for i in _PERM]
|
||||
return [v[i] ^ v[i + 8] for i in range(8)] + [v[i + 8] ^ cv[i] for i in range(8)]
|
||||
|
||||
|
||||
def _words(block):
|
||||
return struct.unpack("<16I", block.ljust(64, b"\0"))
|
||||
|
||||
|
||||
def _chunk_output(chunk, counter):
|
||||
"""(cv_in, block_words, counter, block_len, flags) of the chunk's last block,
|
||||
plus the chaining value entering it."""
|
||||
cv = _IV
|
||||
blocks = [chunk[i:i + 64] for i in range(0, len(chunk), 64)] or [b""]
|
||||
for n, blk in enumerate(blocks[:-1]):
|
||||
flags = _CHUNK_START if n == 0 else 0
|
||||
cv = _compress(cv, _words(blk), counter, 64, flags)[:8]
|
||||
last = blocks[-1]
|
||||
flags = _CHUNK_END | (_CHUNK_START if len(blocks) == 1 else 0)
|
||||
return cv, _words(last), counter, len(last), flags
|
||||
|
||||
|
||||
def blake3_hex(data: bytes) -> str:
|
||||
"""Plain 32-byte BLAKE3 of `data`, lowercase hex (the reference algorithm)."""
|
||||
chunks = [data[i:i + 1024] for i in range(0, len(data), 1024)] or [b""]
|
||||
|
||||
def subtree(lo, hi, root):
|
||||
n = hi - lo
|
||||
if n == 1:
|
||||
cv, w, ctr, ln, fl = _chunk_output(chunks[lo], lo)
|
||||
return _compress(cv, w, ctr, ln, fl | (_ROOT if root else 0))[:8]
|
||||
split = 1 << ((n - 1).bit_length() - 1) # largest power of two below n
|
||||
left = subtree(lo, lo + split, False)
|
||||
right = subtree(lo + split, hi, False)
|
||||
flags = _PARENT | (_ROOT if root else 0)
|
||||
return _compress(_IV, left + right, 0, 64, flags)[:8]
|
||||
|
||||
return struct.pack("<8I", *subtree(0, len(chunks), True)).hex()
|
||||
|
||||
XMP_KEY = "XML:com.adobe.xmp"
|
||||
IMAGEAPI_NS = "https://git.crkc.xyz/Apps/ImageApi/ns/versions/1.0/"
|
||||
|
||||
|
||||
def _resource_ref(file_hash: str, name: str) -> str:
|
||||
return (f"<stRef:documentID>blake3:{file_hash}</stRef:documentID>"
|
||||
f"<stRef:filePath>{name}</stRef:filePath>")
|
||||
|
||||
|
||||
def build_xmp(source_hash: str, source_name: str, kind: str,
|
||||
references: list[tuple[str, str]]) -> str:
|
||||
"""The packet comfyui-imageapi-versions writes (versioning.build_xmp)."""
|
||||
ingredients = ""
|
||||
if references:
|
||||
lis = "".join(
|
||||
f'<rdf:li rdf:parseType="Resource">{_resource_ref(h, n)}</rdf:li>'
|
||||
for h, n in references
|
||||
)
|
||||
ingredients = f"<xmpMM:Ingredients><rdf:Bag>{lis}</rdf:Bag></xmpMM:Ingredients>"
|
||||
return (
|
||||
'<?xpacket begin="\ufeff" id="W5M0MpCehiHzreSzNTczkc9d"?>'
|
||||
'<x:xmpmeta xmlns:x="adobe:ns:meta/">'
|
||||
'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
|
||||
'<rdf:Description rdf:about=""'
|
||||
' xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"'
|
||||
' xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#"'
|
||||
f' xmlns:imageapi="{IMAGEAPI_NS}">'
|
||||
f'<xmpMM:DerivedFrom rdf:parseType="Resource">{_resource_ref(source_hash, source_name)}</xmpMM:DerivedFrom>'
|
||||
f"{ingredients}<imageapi:versionKind>{kind}</imageapi:versionKind>"
|
||||
"</rdf:Description></rdf:RDF></x:xmpmeta>"
|
||||
'<?xpacket end="w"?>'
|
||||
)
|
||||
|
||||
|
||||
def embed_xmp(png: Path, xmp: str) -> None:
|
||||
"""Insert the XMP as an uncompressed iTXt chunk right after IHDR."""
|
||||
data = png.read_bytes()
|
||||
assert data[:8] == b"\x89PNG\r\n\x1a\n", f"{png} is not a PNG"
|
||||
body = XMP_KEY.encode() + b"\0\0\0\0\0" + xmp.encode("utf-8")
|
||||
chunk = struct.pack(">I", len(body)) + b"iTXt" + body
|
||||
chunk += struct.pack(">I", zlib.crc32(b"iTXt" + body) & 0xFFFFFFFF)
|
||||
ihdr_end = 8 + 4 + 4 + 13 + 4
|
||||
png.write_bytes(data[:ihdr_end] + chunk + data[ihdr_end:])
|
||||
|
||||
|
||||
def link_versions(items: list["Item"], media: Path) -> None:
|
||||
"""Write the XMP link into each `xmp` version. Runs after EXIF stamping:
|
||||
the link is the original's hash over its final bytes."""
|
||||
by_path = {it.rel: it for it in items}
|
||||
for it in items:
|
||||
if not (it.version_of and it.xmp):
|
||||
continue
|
||||
original = media / it.version_of
|
||||
refs = [(blake3_hex((media / r).read_bytes()), Path(r).name) for r in it.references]
|
||||
embed_xmp(media / it.rel,
|
||||
build_xmp(blake3_hex(original.read_bytes()), original.name,
|
||||
it.version_kind, refs))
|
||||
assert by_path[it.version_of].kind == "image"
|
||||
|
||||
|
||||
def render(item: Item, idx: int, media: Path) -> None:
|
||||
out = media / item.rel
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -355,6 +537,7 @@ def main() -> int:
|
||||
if it.copy_of:
|
||||
(media / it.rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(media / it.copy_of, media / it.rel)
|
||||
link_versions(items, media)
|
||||
# Pin mtimes last: exiftool rewrites (and so touches) the files.
|
||||
for it in items:
|
||||
when = it.mtime or it.taken or dt.datetime(2024, 1, 1)
|
||||
@@ -387,6 +570,15 @@ def main() -> int:
|
||||
"excluded_dirs": ["/private"],
|
||||
"places": {k: {"lat": v[0], "lon": v[1]} for k, v in PLACES.items()},
|
||||
"tags": tags,
|
||||
# Photo versions (#191): hidden behind `original` in listings; the
|
||||
# reference photos stay visible. `source` is how ImageApi links it.
|
||||
"versions": [
|
||||
{
|
||||
"original": it.version_of, "version": it.rel, "kind": it.version_kind,
|
||||
"source": "xmp" if it.xmp else "name", "references": it.references,
|
||||
}
|
||||
for it in items if it.version_of
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"path": it.rel, "kind": it.kind, "scenario": it.scenario,
|
||||
@@ -394,9 +586,12 @@ def main() -> int:
|
||||
"taken": it.taken.isoformat() if it.taken else None,
|
||||
"gps": it.gps, "camera": it.camera[1] if it.camera else None,
|
||||
"tags": it.tags, "duplicate_of": it.copy_of,
|
||||
# A duplicate carries its source's bytes, EXIF included.
|
||||
"wall_clock": expected_wall_clock(by_path.get(it.copy_of, it)),
|
||||
"true_instant": expected_true_instant(by_path.get(it.copy_of, it)),
|
||||
"version_of": it.version_of,
|
||||
# A duplicate carries its source's bytes, EXIF included; a
|
||||
# version takes its original's date and GPS (ImageApi copies
|
||||
# them onto the version row).
|
||||
"wall_clock": expected_wall_clock(by_path.get(it.copy_of or it.version_of, it)),
|
||||
"true_instant": expected_true_instant(by_path.get(it.copy_of or it.version_of, it)),
|
||||
}
|
||||
for it in items
|
||||
],
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS photo_version_display;
|
||||
DROP TABLE IF EXISTS photo_version_references;
|
||||
DROP TABLE IF EXISTS photo_versions;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Photo versions (#191): an AI restoration, upscale or edit that sits on
|
||||
-- disk near its original and is shown as the *same photo*.
|
||||
--
|
||||
-- The link is hash-keyed like every other derived table (CLAUDE.md →
|
||||
-- "Multi-library data model"): a version records the blake3
|
||||
-- `content_hash` of its primary original, so the link survives moves,
|
||||
-- renames and an archive handoff. File name and location are hints only.
|
||||
-- The version keeps its own `image_exif` row, so it still downloads and
|
||||
-- thumbnails on its own; listings hide it while its original is reachable
|
||||
-- in the current view (same rule as `duplicate_of_hash`).
|
||||
|
||||
-- A file is a version of at most one original.
|
||||
CREATE TABLE photo_versions (
|
||||
version_hash TEXT PRIMARY KEY NOT NULL,
|
||||
original_hash TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- restored | upscale | edit
|
||||
source TEXT NOT NULL, -- xmp | name | backfill | api
|
||||
created_at BIGINT NOT NULL,
|
||||
-- Denormalized back-ref to a live image_exif row for the version, kept
|
||||
-- current by the back-ref refresh pass (same pattern as face_detections).
|
||||
library_id INTEGER NOT NULL,
|
||||
rel_path TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_photo_versions_original ON photo_versions(original_hash);
|
||||
|
||||
-- Reference photos (extra context fed to the model). Provenance only: a
|
||||
-- reference is never hidden or grouped because of the version.
|
||||
CREATE TABLE photo_version_references (
|
||||
version_hash TEXT NOT NULL,
|
||||
reference_hash TEXT NOT NULL,
|
||||
PRIMARY KEY (version_hash, reference_hash)
|
||||
);
|
||||
CREATE INDEX idx_photo_version_references_ref ON photo_version_references(reference_hash);
|
||||
|
||||
-- The global (not per-user) choice of which file represents a photo.
|
||||
-- No row = show the original.
|
||||
CREATE TABLE photo_version_display (
|
||||
original_hash TEXT PRIMARY KEY NOT NULL,
|
||||
display_hash TEXT NOT NULL,
|
||||
decided_at BIGINT NOT NULL
|
||||
);
|
||||
@@ -430,7 +430,17 @@ pub async fn generate_insight_handler(
|
||||
&mut request.persona_id,
|
||||
);
|
||||
|
||||
// Insights describe the original: asking for one on a photo version
|
||||
// (#191) runs against the original it belongs to.
|
||||
let normalized_path = normalize_path(&request.file_path);
|
||||
let normalized_path = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.original_path_of(&normalized_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(normalized_path);
|
||||
let library = app_state.primary_library();
|
||||
let gen_type = InsightGenerationType::Standard;
|
||||
|
||||
@@ -748,7 +758,17 @@ pub async fn generate_agentic_insight_handler(
|
||||
&mut request.persona_id,
|
||||
);
|
||||
|
||||
// Insights describe the original: asking for one on a photo version
|
||||
// (#191) runs against the original it belongs to.
|
||||
let normalized_path = normalize_path(&request.file_path);
|
||||
let normalized_path = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.original_path_of(&normalized_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(normalized_path);
|
||||
let library = app_state.primary_library();
|
||||
let gen_type = InsightGenerationType::Agentic;
|
||||
|
||||
|
||||
@@ -2544,7 +2544,7 @@ Return ONLY the summary, nothing else."#,
|
||||
);
|
||||
let rows = {
|
||||
let mut dao = self.exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
match dao.query_by_exif(None, None, None, None, None, range) {
|
||||
match dao.query_by_exif(None, None, None, None, None, range, false) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return format!("Error querying photos: {:?}", e),
|
||||
}
|
||||
|
||||
+72
-4
@@ -18,7 +18,7 @@ use prometheus::IntGauge;
|
||||
use crate::camera_tz;
|
||||
use crate::content_hash;
|
||||
use crate::database::models::PrecomputedReel;
|
||||
use crate::database::{ExifDao, PrecomputedReelDao, UtcBackfillCandidate};
|
||||
use crate::database::{ExifDao, PrecomputedReelDao, UtcBackfillCandidate, VersionDao};
|
||||
use crate::date_resolver;
|
||||
use crate::face_watch;
|
||||
use crate::faces;
|
||||
@@ -827,12 +827,14 @@ pub fn backfill_missing_content_hashes(
|
||||
/// content_hash recorded in image_exif but no row in face_detections
|
||||
/// yet. Re-querying image_exif here picks up rows the EXIF write loop
|
||||
/// just inserted alongside any pre-existing rows the watcher walked
|
||||
/// over — covers both new uploads and the initial backlog scan.
|
||||
/// over — covers both new uploads and the initial backlog scan. Photo
|
||||
/// versions (#191) are never candidates: faces come from the original only.
|
||||
pub fn build_face_candidates(
|
||||
library: &libraries::Library,
|
||||
files: &[(PathBuf, String)],
|
||||
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
face_dao: &Arc<Mutex<Box<dyn faces::FaceDao>>>,
|
||||
version_dao: &Arc<Mutex<Box<dyn VersionDao>>>,
|
||||
) -> Vec<face_watch::FaceCandidate> {
|
||||
// Restrict to image files; videos aren't face-scanned in v1 (kamadak
|
||||
// doesn't even register them in image_exif).
|
||||
@@ -859,12 +861,22 @@ pub fn build_face_candidates(
|
||||
}
|
||||
}
|
||||
|
||||
let hashes: Vec<String> = hash_by_path.values().cloned().collect();
|
||||
let versions = version_dao
|
||||
.lock()
|
||||
.expect("version dao")
|
||||
.version_hashes_among(&hashes)
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
let mut dao = face_dao.lock().expect("face dao");
|
||||
for rel_path in image_paths {
|
||||
let Some(hash) = hash_by_path.get(&rel_path) else {
|
||||
continue;
|
||||
};
|
||||
if versions.contains(hash) {
|
||||
continue;
|
||||
}
|
||||
match dao.already_scanned(hash) {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => candidates.push(face_watch::FaceCandidate {
|
||||
@@ -891,9 +903,11 @@ mod tests {
|
||||
|
||||
use crate::database::models::{InsertImageExif, InsertLibrary};
|
||||
use crate::database::test::in_memory_db_connection;
|
||||
use crate::database::{ExifDao, SqliteExifDao, schema};
|
||||
use crate::database::version_dao::NewVersionLink;
|
||||
use crate::database::{ExifDao, SqliteExifDao, SqliteVersionDao, schema};
|
||||
use crate::faces::{FaceDao, SqliteFaceDao};
|
||||
use crate::libraries::Library;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
|
||||
/// Everything `setup` hands back to a test: tempdir, library, shared
|
||||
/// connection, and the two DAOs. Aliased to keep clippy's
|
||||
@@ -1148,7 +1162,9 @@ mod tests {
|
||||
(tmp.path().join("unhashed.jpg"), "unhashed.jpg".to_string()),
|
||||
(tmp.path().join("clip.mp4"), "clip.mp4".to_string()),
|
||||
];
|
||||
let candidates = build_face_candidates(&library, &files, &exif_dao, &face_dao);
|
||||
let version_dao = version_dao_for(&_conn);
|
||||
let candidates =
|
||||
build_face_candidates(&library, &files, &exif_dao, &face_dao, &version_dao);
|
||||
|
||||
assert_eq!(
|
||||
candidates.len(),
|
||||
@@ -1159,6 +1175,58 @@ mod tests {
|
||||
assert_eq!(candidates[0].content_hash, "hash-fresh");
|
||||
}
|
||||
|
||||
fn version_dao_for(conn: &Arc<Mutex<SqliteConnection>>) -> Arc<Mutex<Box<dyn VersionDao>>> {
|
||||
Arc::new(Mutex::new(Box::new(SqliteVersionDao::from_shared(
|
||||
Arc::clone(conn),
|
||||
))))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_face_candidates_skips_photo_versions() {
|
||||
let (tmp, library, conn, exif_dao, face_dao) = setup();
|
||||
insert_exif(&exif_dao, 1, "orig.jpg", Some("hash-orig"));
|
||||
insert_exif(
|
||||
&exif_dao,
|
||||
1,
|
||||
"aiedits/orig.jpg.restored.png",
|
||||
Some("hash-ver"),
|
||||
);
|
||||
let version_dao = version_dao_for(&conn);
|
||||
version_dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.link_version(&NewVersionLink {
|
||||
version_hash: "hash-ver".into(),
|
||||
original_hash: "hash-orig".into(),
|
||||
kind: "restored".into(),
|
||||
source: "xmp".into(),
|
||||
library_id: 1,
|
||||
rel_path: "aiedits/orig.jpg.restored.png".into(),
|
||||
references: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
let files: Vec<(PathBuf, String)> = vec![
|
||||
(tmp.path().join("orig.jpg"), "orig.jpg".to_string()),
|
||||
(
|
||||
tmp.path().join("aiedits/orig.jpg.restored.png"),
|
||||
"aiedits/orig.jpg.restored.png".to_string(),
|
||||
),
|
||||
];
|
||||
let candidates =
|
||||
build_face_candidates(&library, &files, &exif_dao, &face_dao, &version_dao);
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert_eq!(candidates[0].rel_path, "orig.jpg");
|
||||
|
||||
// The SQL backlog drain agrees.
|
||||
let unscanned = face_dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.list_unscanned_candidates(1, 10)
|
||||
.unwrap();
|
||||
assert_eq!(unscanned.len(), 1);
|
||||
assert_eq!(unscanned[0].0, "orig.jpg");
|
||||
}
|
||||
|
||||
// --- True-instant backfill drain (ADR-0001, ticket #118) -------------
|
||||
|
||||
use crate::database::SqlitePrecomputedReelDao;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Link photo versions that were ingested before linking existed (#191).
|
||||
//!
|
||||
//! **Default is a dry run**: nothing is written. It prints a TSV of every
|
||||
//! proposed link (`status method library_id version_path original_path
|
||||
//! phash_distance kind ...`) and a summary on stderr.
|
||||
//!
|
||||
//! - `exact` rows (the PNG's XMP `DerivedFrom` hash, or the
|
||||
//! `<name>.<kind>[.<n>].<ext>` convention next to an indexed original) are
|
||||
//! the same links the watcher makes at ingest; `--apply` writes them
|
||||
//! without review.
|
||||
//! - `review` rows (ComfyUI prompt `LoadImage` input, Upscayl-style names,
|
||||
//! nearest pHash) are heuristic. Save the dry-run output, delete the rows
|
||||
//! you don't want, and pass the file as `--reviewed`; only those are
|
||||
//! written, and each is checked against the database first so a stale file
|
||||
//! can't link a path that now holds different bytes.
|
||||
//! - `unresolved` rows are listed, never guessed.
|
||||
//!
|
||||
//! `--apply` first takes a WAL-safe snapshot of the database
|
||||
//! (`VACUUM INTO`) and refuses to write if that fails. Prove it on a copy of
|
||||
//! the database first. The library roots are only ever read.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use clap::Parser;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::{BigInt, Integer, Nullable, Text};
|
||||
|
||||
use image_api::database::version_dao::VersionDao;
|
||||
use image_api::database::{SqliteVersionDao, connect};
|
||||
use image_api::libraries;
|
||||
use image_api::versions::backfill::{self, FileRow, Status};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "link_versions")]
|
||||
#[command(about = "Propose (dry run) or write links from photo versions to their originals")]
|
||||
struct Args {
|
||||
/// Write the links. Without it nothing is changed.
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
|
||||
/// A reviewed copy of the dry-run TSV: its `review` rows are linked too.
|
||||
/// Only meaningful with --apply.
|
||||
#[arg(long, value_name = "FILE")]
|
||||
reviewed: Option<PathBuf>,
|
||||
|
||||
/// Directory for the pre-apply snapshot (default: next to the database).
|
||||
#[arg(long, value_name = "DIR")]
|
||||
backup_dir: Option<PathBuf>,
|
||||
|
||||
/// Only consider this library id (repeatable). Default: every library.
|
||||
#[arg(long = "library", value_name = "ID")]
|
||||
libraries: Vec<i32>,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
struct DbRow {
|
||||
#[diesel(sql_type = Integer)]
|
||||
library_id: i32,
|
||||
#[diesel(sql_type = Text)]
|
||||
rel_path: String,
|
||||
#[diesel(sql_type = Text)]
|
||||
content_hash: String,
|
||||
#[diesel(sql_type = Nullable<BigInt>)]
|
||||
phash_64: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
struct Count {
|
||||
#[diesel(sql_type = BigInt)]
|
||||
n: i64,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
struct Hash {
|
||||
#[diesel(sql_type = Text)]
|
||||
version_hash: String,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
env_logger::init();
|
||||
dotenv::dotenv().ok();
|
||||
let args = Args::parse();
|
||||
if args.reviewed.is_some() && !args.apply {
|
||||
anyhow::bail!("--reviewed only applies together with --apply (the default is a dry run)");
|
||||
}
|
||||
|
||||
let mut conn = connect();
|
||||
let has_table = diesel::sql_query(
|
||||
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'photo_versions'",
|
||||
)
|
||||
.get_result::<Count>(&mut conn)?;
|
||||
if has_table.n == 0 {
|
||||
anyhow::bail!(
|
||||
"this database has no photo_versions table; start the server once (it runs the \
|
||||
migrations) or point DATABASE_URL at a migrated copy"
|
||||
);
|
||||
}
|
||||
|
||||
let mut libs = libraries::load_all(&mut conn);
|
||||
if !args.libraries.is_empty() {
|
||||
libs.retain(|l| args.libraries.contains(&l.id));
|
||||
}
|
||||
if libs.is_empty() {
|
||||
anyhow::bail!("no matching libraries");
|
||||
}
|
||||
let lib_ids: HashSet<i32> = libs.iter().map(|l| l.id).collect();
|
||||
|
||||
let rows: Vec<FileRow> = diesel::sql_query(
|
||||
"SELECT library_id, rel_path, content_hash, phash_64 FROM image_exif \
|
||||
WHERE content_hash IS NOT NULL ORDER BY library_id, rel_path",
|
||||
)
|
||||
.load::<DbRow>(&mut conn)?
|
||||
.into_iter()
|
||||
.filter(|r| lib_ids.contains(&r.library_id))
|
||||
.map(|r| FileRow {
|
||||
library_id: r.library_id,
|
||||
rel_path: r.rel_path,
|
||||
content_hash: r.content_hash,
|
||||
phash: r.phash_64,
|
||||
})
|
||||
.collect();
|
||||
let linked: HashSet<String> = diesel::sql_query("SELECT version_hash FROM photo_versions")
|
||||
.load::<Hash>(&mut conn)?
|
||||
.into_iter()
|
||||
.map(|h| h.version_hash)
|
||||
.collect();
|
||||
|
||||
eprintln!(
|
||||
"{} hashed image/video rows, {} version links already recorded",
|
||||
rows.len(),
|
||||
linked.len()
|
||||
);
|
||||
let proposals = backfill::plan(&libs, &rows, &linked);
|
||||
|
||||
println!("{}", backfill::TSV_HEADER);
|
||||
for p in &proposals {
|
||||
println!("{}", p.to_tsv());
|
||||
}
|
||||
let count = |s: Status| proposals.iter().filter(|p| p.status == s).count();
|
||||
eprintln!(
|
||||
"\nproposals: {} exact (apply without review), {} review (need --reviewed), {} unresolved",
|
||||
count(Status::Exact),
|
||||
count(Status::Review),
|
||||
count(Status::Unresolved)
|
||||
);
|
||||
for method in ["xmp", "name", "comfy-loader", "name-prefix", "phash"] {
|
||||
let n = proposals.iter().filter(|p| p.method == method).count();
|
||||
if n > 0 {
|
||||
eprintln!(" {method}: {n}");
|
||||
}
|
||||
}
|
||||
|
||||
if !args.apply {
|
||||
eprintln!("dry run: nothing written. Re-run with --apply to write the exact links.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let reviewed = match &args.reviewed {
|
||||
Some(path) => {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let (links, skipped) = backfill::parse_reviewed(&text);
|
||||
eprintln!(
|
||||
"reviewed file: {} review row(s) to link, {} other line(s) ignored",
|
||||
links.len(),
|
||||
skipped
|
||||
);
|
||||
links
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let backup = backup_database(&mut conn, args.backup_dir.as_deref())?;
|
||||
eprintln!("snapshot written: {}", backup.display());
|
||||
drop(conn);
|
||||
|
||||
let dao: Arc<Mutex<Box<dyn VersionDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteVersionDao::new())));
|
||||
let mut guard = dao.lock().unwrap();
|
||||
let report = backfill::apply(guard.as_mut(), &proposals, &reviewed, &rows);
|
||||
let synced = guard
|
||||
.sync_version_metadata()
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
||||
eprintln!(
|
||||
"linked {} ({} already linked, {} refused, {} stale reviewed row(s)); \
|
||||
date/GPS/camera copied onto {} version row(s)",
|
||||
report.linked, report.already_linked, report.refused, report.stale, synced
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Snapshot the live database with `VACUUM INTO`, which is consistent with
|
||||
/// the WAL (a plain file copy is not).
|
||||
fn backup_database(
|
||||
conn: &mut diesel::SqliteConnection,
|
||||
dir: Option<&std::path::Path>,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let url = dotenv::var("DATABASE_URL")?;
|
||||
let db_path = PathBuf::from(url.trim_start_matches("sqlite://"));
|
||||
let dir = match dir {
|
||||
Some(d) => d.to_path_buf(),
|
||||
None => db_path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(".")),
|
||||
};
|
||||
let stem = db_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "database".into());
|
||||
let target = dir.join(format!(
|
||||
"{stem}.pre-link_versions-{}.bak",
|
||||
chrono::Utc::now().format("%Y%m%dT%H%M%SZ")
|
||||
));
|
||||
if target.exists() {
|
||||
anyhow::bail!("{} already exists", target.display());
|
||||
}
|
||||
let escaped = target.to_string_lossy().replace('\'', "''");
|
||||
diesel::sql_query(format!("VACUUM INTO '{escaped}'"))
|
||||
.execute(conn)
|
||||
.map_err(|e| anyhow::anyhow!("snapshot failed, nothing was written: {e}"))?;
|
||||
Ok(target)
|
||||
}
|
||||
+11
-1
@@ -82,6 +82,10 @@ pub struct SearchHit {
|
||||
/// Cosine similarity in [-1, 1]. In practice OpenAI CLIP returns
|
||||
/// 0.10–0.40 for the typical photo library.
|
||||
pub score: f32,
|
||||
/// `display_path` / `version_count` (#191); absent for a photo without
|
||||
/// versions. `rel_path` is always the original's.
|
||||
#[serde(flatten)]
|
||||
pub versions: crate::versions::VersionFields,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -230,11 +234,13 @@ pub async fn score_photos(
|
||||
/// to a row are skipped. Shared by both endpoints.
|
||||
pub fn resolve_hits(
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
version_dao: &Mutex<Box<dyn crate::database::VersionDao>>,
|
||||
scored: &[(f32, String)],
|
||||
) -> Vec<SearchHit> {
|
||||
if scored.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let summaries = crate::versions::load_summaries(version_dao);
|
||||
let hashes: Vec<String> = scored.iter().map(|(_, h)| h.clone()).collect();
|
||||
let mut dao = exif_dao.lock().expect("exif dao");
|
||||
let path_map = dao.get_rel_paths_for_hashes(&hashes).unwrap_or_else(|e| {
|
||||
@@ -258,11 +264,15 @@ pub fn resolve_hits(
|
||||
.get(hash)
|
||||
.and_then(|paths| paths.first().cloned())
|
||||
.unwrap_or(row.file_path);
|
||||
let versions = crate::versions::VersionFields::from_summary(
|
||||
summaries.get(&(row.library_id, rel_path.clone())),
|
||||
);
|
||||
results.push(SearchHit {
|
||||
library_id: row.library_id,
|
||||
rel_path,
|
||||
content_hash: hash.clone(),
|
||||
score: *score,
|
||||
versions,
|
||||
});
|
||||
}
|
||||
results
|
||||
@@ -343,7 +353,7 @@ pub async fn search_photos(
|
||||
let end = (offset + limit).min(total_matching);
|
||||
scored.hits[offset..end].to_vec()
|
||||
};
|
||||
let results = resolve_hits(&exif_dao, &page);
|
||||
let results = resolve_hits(&exif_dao, &state.version_dao, &page);
|
||||
|
||||
Ok(HttpResponse::Ok().json(SearchResponse {
|
||||
query: q_text,
|
||||
|
||||
@@ -108,6 +108,19 @@ pub struct PhotosResponse {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub photo_libraries: Vec<i32>,
|
||||
|
||||
/// Photo versions (#191), parallel to `photos`. `photos[i]` is always
|
||||
/// the *original's* path (it anchors tags, sort order and dates).
|
||||
/// `photo_display_paths[i]` is the file to show instead when the global
|
||||
/// display choice isn't the original, else `null`; skipped when no photo
|
||||
/// on the page has one.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub photo_display_paths: Vec<Option<String>>,
|
||||
|
||||
/// How many versions stand behind `photos[i]`; skipped when the page has
|
||||
/// none, so responses without versions are unchanged.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub photo_version_counts: Vec<u32>,
|
||||
|
||||
// Pagination metadata (only present when limit is set)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_count: Option<i64>,
|
||||
@@ -180,6 +193,13 @@ pub struct FilesRequest {
|
||||
/// inside a group.
|
||||
#[serde(default)]
|
||||
pub include_duplicates: Option<bool>,
|
||||
|
||||
/// When true, list photo versions (#191) as photos of their own instead
|
||||
/// of hiding them behind their original. Default false. Counterpart of
|
||||
/// `include_duplicates`; a version whose original is not reachable in
|
||||
/// the request's view is listed either way.
|
||||
#[serde(default)]
|
||||
pub include_versions: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
||||
@@ -495,6 +515,11 @@ pub struct ExifSummary {
|
||||
/// Capture wall clock to display (naive ISO-8601; see
|
||||
/// `MetadataResponse::wall_clock`).
|
||||
pub wall_clock: Option<String>,
|
||||
/// `display_path` / `version_count` (#191); absent for a photo without
|
||||
/// versions. `file_path` is always the original's (versions are hidden
|
||||
/// from this listing while their original is reachable).
|
||||
#[serde(flatten)]
|
||||
pub versions: crate::versions::VersionFields,
|
||||
}
|
||||
|
||||
impl ExifSummary {
|
||||
@@ -525,6 +550,7 @@ impl ExifSummary {
|
||||
date_utc: r.date_utc,
|
||||
camera_offset_minutes: r.camera_offset_minutes,
|
||||
date_taken_tz_source: r.date_taken_tz_source,
|
||||
versions: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+128
-14
@@ -8,6 +8,7 @@ use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
use crate::database::models::{
|
||||
Favorite, ImageExif, InsertFavorite, InsertImageExif, InsertUser, User,
|
||||
};
|
||||
use crate::database::version_dao::{NOT_A_VERSION_CLAUSE, visible_version_clause};
|
||||
use crate::otel::trace_db_call;
|
||||
|
||||
/// Wire shape for a single member of a duplicate group, returned by
|
||||
@@ -54,6 +55,7 @@ pub mod reconcile;
|
||||
pub mod schema;
|
||||
pub mod search_dao;
|
||||
pub mod user_ai_prefs_dao;
|
||||
pub mod version_dao;
|
||||
|
||||
pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao};
|
||||
pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao};
|
||||
@@ -71,6 +73,7 @@ pub use precomputed_reel_dao::{PrecomputedReelDao, SqlitePrecomputedReelDao};
|
||||
pub use preview_dao::{PreviewDao, SqlitePreviewDao};
|
||||
pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao};
|
||||
pub use user_ai_prefs_dao::{SqliteUserAiPrefsDao, UserAiPrefsDao};
|
||||
pub use version_dao::{SqliteVersionDao, VersionDao};
|
||||
|
||||
pub trait UserDao {
|
||||
fn create_user(&mut self, user: &str, password: &str) -> Option<User>;
|
||||
@@ -304,11 +307,24 @@ impl SqliteFavoriteDao {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl SqliteFavoriteDao {
|
||||
/// Test-only constructor sharing an already-wrapped connection.
|
||||
pub fn from_shared(connection: Arc<Mutex<SqliteConnection>>) -> Self {
|
||||
SqliteFavoriteDao { connection }
|
||||
}
|
||||
}
|
||||
|
||||
impl FavoriteDao for SqliteFavoriteDao {
|
||||
fn add_favorite(&mut self, user_id: i32, favorite_path: &str) -> Result<usize, DbError> {
|
||||
use schema::favorites::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get FavoriteDao");
|
||||
// Favoriting a photo version favorites its original (#191).
|
||||
let favorite_path = &version_dao::canonical_rel_path(connection.deref_mut(), favorite_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| favorite_path.to_string());
|
||||
|
||||
if favorites
|
||||
.filter(userid.eq(user_id).and(rel_path.eq(&favorite_path)))
|
||||
@@ -330,9 +346,14 @@ impl FavoriteDao for SqliteFavoriteDao {
|
||||
fn remove_favorite(&mut self, user_id: i32, favorite_path: String) {
|
||||
use schema::favorites::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().unwrap();
|
||||
let favorite_path = version_dao::canonical_rel_path(connection.deref_mut(), &favorite_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(favorite_path);
|
||||
diesel::delete(favorites)
|
||||
.filter(userid.eq(user_id).and(rel_path.eq(favorite_path)))
|
||||
.execute(self.connection.lock().unwrap().deref_mut())
|
||||
.execute(connection.deref_mut())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -401,6 +422,10 @@ pub trait ExifDao: Sync + Send {
|
||||
/// or the permanent floor) through `date_view::row_true_instant`'s
|
||||
/// per-source fallback. Every row returned satisfies
|
||||
/// [`InstantRange::contains`](crate::date_view::InstantRange::contains).
|
||||
///
|
||||
/// `include_versions=false` drops version rows whose original is
|
||||
/// reachable in the view (`library_id`, or every library when `None`);
|
||||
/// see [`visible_version_clause`].
|
||||
fn query_by_exif(
|
||||
&mut self,
|
||||
library_id: Option<i32>,
|
||||
@@ -409,6 +434,7 @@ pub trait ExifDao: Sync + Send {
|
||||
lens_model: Option<&str>,
|
||||
gps_bounds: Option<(f64, f64, f64, f64)>, // (min_lat, max_lat, min_lon, max_lon)
|
||||
date_range: Option<crate::date_view::InstantRange>,
|
||||
include_versions: bool,
|
||||
) -> Result<Vec<ImageExif>, DbError>;
|
||||
|
||||
/// Get distinct camera makes with counts
|
||||
@@ -653,6 +679,10 @@ pub trait ExifDao: Sync + Send {
|
||||
///
|
||||
/// Returns full rows so the caller can read the date triple
|
||||
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`).
|
||||
///
|
||||
/// `view_library_ids` is the whole view the caller is assembling (it
|
||||
/// queries one library at a time): a photo version is left out while its
|
||||
/// original has a row in any of them.
|
||||
fn get_memories_in_window(
|
||||
&mut self,
|
||||
library_id: i32,
|
||||
@@ -660,6 +690,7 @@ pub trait ExifDao: Sync + Send {
|
||||
conv_hi: i64,
|
||||
unconv_lo: i64,
|
||||
unconv_hi: i64,
|
||||
view_library_ids: &[i32],
|
||||
) -> Result<Vec<ImageExif>, DbError>;
|
||||
|
||||
/// Return image rows that have a `content_hash` but no `phash_64`,
|
||||
@@ -809,11 +840,16 @@ pub trait ExifDao: Sync + Send {
|
||||
/// `duplicate_of_hash IS NOT NULL` so the default photo listing hides
|
||||
/// demoted siblings; the Apollo duplicates modal passes `true` to
|
||||
/// see both survivors and demoted members inside a group.
|
||||
///
|
||||
/// `include_versions=false` likewise hides photo versions while their
|
||||
/// original is reachable in the same view (an unscoped call counts every
|
||||
/// library; a scoped one only `library_ids`).
|
||||
fn list_rel_paths_for_libraries(
|
||||
&mut self,
|
||||
library_ids: &[i32],
|
||||
path_prefix: Option<&str>,
|
||||
include_duplicates: bool,
|
||||
include_versions: bool,
|
||||
) -> Result<Vec<(i32, String)>, DbError>;
|
||||
|
||||
/// Delete a single image_exif row scoped to `(library_id, rel_path)`.
|
||||
@@ -897,6 +933,12 @@ fn classify_image_exif_error(e: &anyhow::Error) -> DbErrorKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(diesel::QueryableByName)]
|
||||
struct OriginalHashRow {
|
||||
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||
original_hash: String,
|
||||
}
|
||||
|
||||
pub struct SqliteExifDao {
|
||||
connection: Arc<Mutex<SqliteConnection>>,
|
||||
}
|
||||
@@ -1101,6 +1143,7 @@ impl ExifDao for SqliteExifDao {
|
||||
lens_model_filter: Option<&str>,
|
||||
gps_bounds: Option<(f64, f64, f64, f64)>,
|
||||
date_range: Option<crate::date_view::InstantRange>,
|
||||
include_versions: bool,
|
||||
) -> Result<Vec<ImageExif>, DbError> {
|
||||
trace_db_call("query", "query_by_exif", |_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
@@ -1108,6 +1151,13 @@ impl ExifDao for SqliteExifDao {
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
let mut query = image_exif.into_boxed();
|
||||
|
||||
if !include_versions {
|
||||
let view: Vec<i32> = library_id_filter.into_iter().collect();
|
||||
query = query.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
&visible_version_clause(&view),
|
||||
));
|
||||
}
|
||||
|
||||
// Library scope (most-selective filter — apply first so the
|
||||
// `(library_id, ...)` indexes are eligible).
|
||||
if let Some(lib_id) = library_id_filter {
|
||||
@@ -1175,6 +1225,9 @@ impl ExifDao for SqliteExifDao {
|
||||
|
||||
image_exif
|
||||
.filter(camera_make.is_not_null())
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
&visible_version_clause(&[]),
|
||||
))
|
||||
.group_by(camera_make)
|
||||
.select((camera_make, count(id)))
|
||||
.order(count(id).desc())
|
||||
@@ -1200,6 +1253,9 @@ impl ExifDao for SqliteExifDao {
|
||||
image_exif
|
||||
.filter(camera_make.is_not_null())
|
||||
.filter(camera_model.is_not_null())
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
&visible_version_clause(&[]),
|
||||
))
|
||||
.group_by((camera_make, camera_model))
|
||||
.select((camera_make, camera_model, count(id)))
|
||||
.order(count(id).desc())
|
||||
@@ -1260,6 +1316,9 @@ impl ExifDao for SqliteExifDao {
|
||||
// Query all photos with non-null GPS coordinates
|
||||
let mut query = image_exif
|
||||
.filter(gps_latitude.is_not_null().and(gps_longitude.is_not_null()))
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
&visible_version_clause(&[]),
|
||||
))
|
||||
.into_boxed();
|
||||
|
||||
// Apply path filtering
|
||||
@@ -1570,6 +1629,10 @@ impl ExifDao for SqliteExifDao {
|
||||
.filter(library_id.eq(library_id_val))
|
||||
.filter(clip_embedding.is_null())
|
||||
.filter(content_hash.is_not_null())
|
||||
// Versions are never embedded: invented detail would pollute search.
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
NOT_A_VERSION_CLAUSE,
|
||||
))
|
||||
.filter(diesel::dsl::not(diesel::dsl::exists(failed)))
|
||||
.select((rel_path, content_hash.assume_not_null()))
|
||||
.order(id.asc())
|
||||
@@ -1683,6 +1746,9 @@ impl ExifDao for SqliteExifDao {
|
||||
let mut query = image_exif
|
||||
.filter(content_hash.is_not_null())
|
||||
.filter(clip_embedding.is_not_null())
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
NOT_A_VERSION_CLAUSE,
|
||||
))
|
||||
.into_boxed();
|
||||
if !library_ids_val.is_empty() {
|
||||
query = query.filter(library_id.eq_any(library_ids_val));
|
||||
@@ -1877,6 +1943,7 @@ impl ExifDao for SqliteExifDao {
|
||||
conv_hi: i64,
|
||||
unconv_lo: i64,
|
||||
unconv_hi: i64,
|
||||
view_library_ids: &[i32],
|
||||
) -> Result<Vec<ImageExif>, DbError> {
|
||||
trace_db_call("query", "get_memories_in_window", |_span| {
|
||||
use crate::database::schema::image_exif::dsl as d;
|
||||
@@ -1885,6 +1952,9 @@ impl ExifDao for SqliteExifDao {
|
||||
|
||||
d::image_exif
|
||||
.filter(d::library_id.eq(library_id))
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
&visible_version_clause(view_library_ids),
|
||||
))
|
||||
.filter(d::date_taken.is_not_null())
|
||||
.filter(d::date_taken.ge(unconv_lo))
|
||||
.filter(d::date_taken.le(unconv_hi))
|
||||
@@ -1936,13 +2006,27 @@ impl ExifDao for SqliteExifDao {
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?
|
||||
.flatten();
|
||||
|
||||
// A version shares its annotations (tags, favorites) with its
|
||||
// original, so its hash set includes the original's.
|
||||
let paths = match hash {
|
||||
Some(h) => image_exif
|
||||
.filter(content_hash.eq(h))
|
||||
.select(rel_path)
|
||||
.distinct()
|
||||
.load::<String>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?,
|
||||
Some(h) => {
|
||||
let original: Option<String> = diesel::sql_query(
|
||||
"SELECT original_hash FROM photo_versions WHERE version_hash = ?",
|
||||
)
|
||||
.bind::<diesel::sql_types::Text, _>(&h)
|
||||
.get_result::<OriginalHashRow>(connection.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?
|
||||
.map(|r| r.original_hash);
|
||||
let mut hashes = vec![h];
|
||||
hashes.extend(original);
|
||||
image_exif
|
||||
.filter(content_hash.eq_any(hashes))
|
||||
.select(rel_path)
|
||||
.distinct()
|
||||
.load::<String>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?
|
||||
}
|
||||
None => vec![rel_path_val.to_string()],
|
||||
};
|
||||
|
||||
@@ -2041,6 +2125,7 @@ impl ExifDao for SqliteExifDao {
|
||||
library_ids: &[i32],
|
||||
path_prefix: Option<&str>,
|
||||
include_duplicates: bool,
|
||||
include_versions: bool,
|
||||
) -> Result<Vec<(i32, String)>, DbError> {
|
||||
trace_db_call("query", "list_rel_paths_for_libraries", |_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
@@ -2096,6 +2181,12 @@ impl ExifDao for SqliteExifDao {
|
||||
}
|
||||
}
|
||||
|
||||
if !include_versions {
|
||||
query = query.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
&visible_version_clause(library_ids),
|
||||
));
|
||||
}
|
||||
|
||||
query
|
||||
.load::<(i32, String)>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
@@ -2261,6 +2352,9 @@ impl ExifDao for SqliteExifDao {
|
||||
use schema::image_exif::dsl::*;
|
||||
let mut q = image_exif
|
||||
.filter(content_hash.eq_any(&dup_hashes))
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
NOT_A_VERSION_CLAUSE,
|
||||
))
|
||||
.select((
|
||||
library_id,
|
||||
rel_path,
|
||||
@@ -2341,6 +2435,9 @@ impl ExifDao for SqliteExifDao {
|
||||
let mut q = image_exif
|
||||
.filter(content_hash.is_not_null())
|
||||
.filter(phash_64.is_not_null())
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
NOT_A_VERSION_CLAUSE,
|
||||
))
|
||||
.select((
|
||||
library_id,
|
||||
rel_path,
|
||||
@@ -2419,6 +2516,9 @@ impl ExifDao for SqliteExifDao {
|
||||
|
||||
let mut q = image_exif
|
||||
.filter(content_hash.is_not_null())
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
NOT_A_VERSION_CLAUSE,
|
||||
))
|
||||
.select((library_id, rel_path))
|
||||
.into_boxed();
|
||||
if let Some(lib) = library_id_filter {
|
||||
@@ -2747,13 +2847,13 @@ mod exif_dao_tests {
|
||||
|
||||
// Union: both rows.
|
||||
let all = dao
|
||||
.query_by_exif(None, None, None, None, None, None)
|
||||
.query_by_exif(None, None, None, None, None, None, false)
|
||||
.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
|
||||
// Scoped to lib 2: only archive row.
|
||||
let lib2 = dao
|
||||
.query_by_exif(Some(2), None, None, None, None, None)
|
||||
.query_by_exif(Some(2), None, None, None, None, None, false)
|
||||
.unwrap();
|
||||
assert_eq!(lib2.len(), 1);
|
||||
assert_eq!(lib2[0].file_path, "archive/a.jpg");
|
||||
@@ -2906,6 +3006,7 @@ mod exif_dao_tests {
|
||||
None,
|
||||
None,
|
||||
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(paths(rows), vec!["redated.jpg"]);
|
||||
@@ -2947,6 +3048,7 @@ mod exif_dao_tests {
|
||||
None,
|
||||
None,
|
||||
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(paths(rows), vec!["jul5_early.jpg"]);
|
||||
@@ -2977,6 +3079,7 @@ mod exif_dao_tests {
|
||||
None,
|
||||
None,
|
||||
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(paths(in_day), vec!["tokyo.jpg"]);
|
||||
@@ -2990,6 +3093,7 @@ mod exif_dao_tests {
|
||||
None,
|
||||
None,
|
||||
jul5_ny(Some(1_720_195_200), Some(JUL5_NY_END)),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(afternoon.is_empty());
|
||||
@@ -3019,6 +3123,7 @@ mod exif_dao_tests {
|
||||
None,
|
||||
None,
|
||||
jul5_ny(Some(JUL5_NY_START), None),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(paths(from_only), vec!["a.jpg"]);
|
||||
@@ -3030,12 +3135,13 @@ mod exif_dao_tests {
|
||||
None,
|
||||
None,
|
||||
jul5_ny(None, Some(JUL5_NY_START)),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(to_only.is_empty());
|
||||
assert!(jul5_ny(None, None).is_none());
|
||||
let all = dao
|
||||
.query_by_exif(None, None, None, None, None, None)
|
||||
.query_by_exif(None, None, None, None, None, None, false)
|
||||
.unwrap();
|
||||
assert_eq!(paths(all), vec!["a.jpg", "undated.jpg"]);
|
||||
}
|
||||
@@ -3219,7 +3325,9 @@ mod exif_dao_tests {
|
||||
insert_row_with_source(&mut dao, 1, "nodate.jpg", None, None);
|
||||
|
||||
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
|
||||
let rows = dao.get_memories_in_window(1, clo, chi, ulo, uhi).unwrap();
|
||||
let rows = dao
|
||||
.get_memories_in_window(1, clo, chi, ulo, uhi, &[])
|
||||
.unwrap();
|
||||
let paths: std::collections::HashSet<String> =
|
||||
rows.into_iter().map(|r| r.file_path).collect();
|
||||
assert!(
|
||||
@@ -3252,8 +3360,12 @@ mod exif_dao_tests {
|
||||
Some("exif"),
|
||||
);
|
||||
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
|
||||
let lib1 = dao.get_memories_in_window(1, clo, chi, ulo, uhi).unwrap();
|
||||
let lib2 = dao.get_memories_in_window(2, clo, chi, ulo, uhi).unwrap();
|
||||
let lib1 = dao
|
||||
.get_memories_in_window(1, clo, chi, ulo, uhi, &[])
|
||||
.unwrap();
|
||||
let lib2 = dao
|
||||
.get_memories_in_window(2, clo, chi, ulo, uhi, &[])
|
||||
.unwrap();
|
||||
assert_eq!(lib1.len(), 1);
|
||||
assert_eq!(lib1[0].file_path, "main/x.jpg");
|
||||
assert_eq!(lib2.len(), 1);
|
||||
@@ -3318,7 +3430,9 @@ mod exif_dao_tests {
|
||||
let chi = now_ts - year;
|
||||
let ulo = now_ts - year - 86_400;
|
||||
let uhi = now_ts;
|
||||
let rows = dao.get_memories_in_window(1, clo, chi, ulo, uhi).unwrap();
|
||||
let rows = dao
|
||||
.get_memories_in_window(1, clo, chi, ulo, uhi, &[])
|
||||
.unwrap();
|
||||
let paths: std::collections::HashSet<String> =
|
||||
rows.into_iter().map(|r| r.file_path).collect();
|
||||
assert!(
|
||||
|
||||
@@ -345,6 +345,33 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
photo_version_display (original_hash) {
|
||||
original_hash -> Text,
|
||||
display_hash -> Text,
|
||||
decided_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
photo_version_references (version_hash, reference_hash) {
|
||||
version_hash -> Text,
|
||||
reference_hash -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
photo_versions (version_hash) {
|
||||
version_hash -> Text,
|
||||
original_hash -> Text,
|
||||
kind -> Text,
|
||||
source -> Text,
|
||||
created_at -> BigInt,
|
||||
library_id -> Integer,
|
||||
rel_path -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
precomputed_reels (id) {
|
||||
id -> Integer,
|
||||
@@ -392,6 +419,9 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
personas,
|
||||
persons,
|
||||
photo_insights,
|
||||
photo_version_display,
|
||||
photo_version_references,
|
||||
photo_versions,
|
||||
precomputed_reels,
|
||||
search_history,
|
||||
tagged_photo,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -65,6 +65,13 @@ lazy_static! {
|
||||
static ref FOLDER_PAIR_CACHE: Mutex<Option<FolderPairCacheEntry>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
/// Drop both caches. Called when a version link appears: the version
|
||||
/// leaves the duplicate groups, so cached clusters that include it are stale.
|
||||
pub fn invalidate_caches() {
|
||||
invalidate_perceptual_cache();
|
||||
invalidate_folder_pair_cache();
|
||||
}
|
||||
|
||||
/// Drop the perceptual-cluster cache. Called from `resolve`/`unresolve`
|
||||
/// so the next modal open reflects the soft-mark change immediately.
|
||||
fn invalidate_perceptual_cache() {
|
||||
|
||||
+25
-3
@@ -569,12 +569,16 @@ impl FaceDao for SqliteFaceDao {
|
||||
// be filtered client-side and re-pulled every tick forever
|
||||
// because no marker row is written for excluded paths.
|
||||
let ext_predicate = image_path_predicate("rel_path");
|
||||
// Photo versions (#191) are never scanned: invented detail
|
||||
// must not seed person clusters.
|
||||
let not_version = crate::database::version_dao::not_a_version_clause_for("e");
|
||||
let sql = format!(
|
||||
"SELECT rel_path, content_hash \
|
||||
FROM image_exif e \
|
||||
WHERE library_id = ? \
|
||||
AND content_hash IS NOT NULL \
|
||||
AND {ext_predicate} \
|
||||
AND {not_version} \
|
||||
AND NOT EXISTS ( \
|
||||
SELECT 1 FROM face_detections f \
|
||||
WHERE f.content_hash = e.content_hash \
|
||||
@@ -928,7 +932,11 @@ impl FaceDao for SqliteFaceDao {
|
||||
// hash can have many rows (multiple detected faces) but we
|
||||
// want it counted once.
|
||||
let scanned: i64 = {
|
||||
let mut q = face_detections::table.into_boxed();
|
||||
let mut q = face_detections::table
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
"content_hash NOT IN (SELECT version_hash FROM photo_versions)",
|
||||
))
|
||||
.into_boxed();
|
||||
if let Some(lib) = library_id {
|
||||
q = q.filter(face_detections::library_id.eq(lib));
|
||||
}
|
||||
@@ -944,6 +952,9 @@ impl FaceDao for SqliteFaceDao {
|
||||
let with_faces: i64 = {
|
||||
let mut q = face_detections::table
|
||||
.filter(face_detections::status.eq("detected"))
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
"content_hash NOT IN (SELECT version_hash FROM photo_versions)",
|
||||
))
|
||||
.into_boxed();
|
||||
if let Some(lib) = library_id {
|
||||
q = q.filter(face_detections::library_id.eq(lib));
|
||||
@@ -960,6 +971,9 @@ impl FaceDao for SqliteFaceDao {
|
||||
let no_faces: i64 = {
|
||||
let mut q = face_detections::table
|
||||
.filter(face_detections::status.eq("no_faces"))
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
"content_hash NOT IN (SELECT version_hash FROM photo_versions)",
|
||||
))
|
||||
.into_boxed();
|
||||
if let Some(lib) = library_id {
|
||||
q = q.filter(face_detections::library_id.eq(lib));
|
||||
@@ -976,6 +990,9 @@ impl FaceDao for SqliteFaceDao {
|
||||
let failed: i64 = {
|
||||
let mut q = face_detections::table
|
||||
.filter(face_detections::status.eq("failed"))
|
||||
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
|
||||
"content_hash NOT IN (SELECT version_hash FROM photo_versions)",
|
||||
))
|
||||
.into_boxed();
|
||||
if let Some(lib) = library_id {
|
||||
q = q.filter(face_detections::library_id.eq(lib));
|
||||
@@ -1006,10 +1023,14 @@ impl FaceDao for SqliteFaceDao {
|
||||
// below 100% for the duration of that backfill.
|
||||
let total_photos: i64 = {
|
||||
let ext_predicate = image_path_predicate("rel_path");
|
||||
// Versions are never face-scanned, so they stay out of the
|
||||
// denominator (and the numerators above).
|
||||
let not_version = crate::database::version_dao::NOT_A_VERSION_CLAUSE;
|
||||
let row: CountRow = if let Some(lib) = library_id {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(DISTINCT content_hash) AS count FROM image_exif \
|
||||
WHERE library_id = ? AND content_hash IS NOT NULL AND {ext_predicate}"
|
||||
WHERE library_id = ? AND content_hash IS NOT NULL AND {ext_predicate} \
|
||||
AND {not_version}"
|
||||
);
|
||||
diesel::sql_query(sql)
|
||||
.bind::<diesel::sql_types::Integer, _>(lib)
|
||||
@@ -1018,7 +1039,8 @@ impl FaceDao for SqliteFaceDao {
|
||||
} else {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(DISTINCT content_hash) AS count FROM image_exif \
|
||||
WHERE content_hash IS NOT NULL AND {ext_predicate}"
|
||||
WHERE content_hash IS NOT NULL AND {ext_predicate} \
|
||||
AND {not_version}"
|
||||
);
|
||||
diesel::sql_query(sql)
|
||||
.get_result(conn.deref_mut())
|
||||
|
||||
+102
-2
@@ -342,6 +342,9 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
req.lens_model.as_deref(),
|
||||
gps_bounds,
|
||||
date_range,
|
||||
// Hiding happens in the listing below; this is only the
|
||||
// set the listing is intersected with.
|
||||
true,
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("EXIF query failed: {:?}", e);
|
||||
@@ -391,6 +394,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
};
|
||||
|
||||
let search_recursively = req.recursive.unwrap_or(false);
|
||||
let include_versions = req.include_versions.unwrap_or(false);
|
||||
if let Some(tag_ids) = &req.tag_ids
|
||||
&& search_recursively
|
||||
{
|
||||
@@ -498,6 +502,21 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
})
|
||||
.collect::<Vec<(FileWithTagCount, i32)>>()
|
||||
})
|
||||
.map(|paired| {
|
||||
// Versions stay hidden behind their original (tag hits can be
|
||||
// either file, since tags bind to the original's path).
|
||||
if include_versions {
|
||||
return paired;
|
||||
}
|
||||
let hidden = hidden_version_rows(&app_state, library.map(|l| l.id));
|
||||
if hidden.is_empty() {
|
||||
return paired;
|
||||
}
|
||||
paired
|
||||
.into_iter()
|
||||
.filter(|(f, lib_id)| !hidden.contains(&(*lib_id, f.file_name.clone())))
|
||||
.collect()
|
||||
})
|
||||
.map(|paired| {
|
||||
// Handle sorting - use helper function that supports EXIF date sorting and pagination
|
||||
let sort_type = req.sort.unwrap_or(NameAsc);
|
||||
@@ -545,10 +564,14 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
span.set_attribute("total_count", total_count.to_string());
|
||||
span.set_status(Status::Ok);
|
||||
|
||||
let (photo_display_paths, photo_version_counts) =
|
||||
version_arrays(&app_state, &tagged_files, &photo_libraries);
|
||||
HttpResponse::Ok().json(PhotosResponse {
|
||||
photos: tagged_files,
|
||||
dirs: vec![],
|
||||
photo_libraries,
|
||||
photo_display_paths,
|
||||
photo_version_counts,
|
||||
total_count: pagination_metadata.0,
|
||||
has_more: pagination_metadata.1,
|
||||
next_offset: pagination_metadata.2,
|
||||
@@ -583,7 +606,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
let include_duplicates = req.include_duplicates.unwrap_or(false);
|
||||
let rows = {
|
||||
let mut dao = exif_dao.lock().expect("Unable to get ExifDao");
|
||||
dao.list_rel_paths_for_libraries(&lib_ids, prefix, include_duplicates)
|
||||
dao.list_rel_paths_for_libraries(&lib_ids, prefix, include_duplicates, include_versions)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("list_rel_paths_for_libraries failed: {:?}", e);
|
||||
Vec::new()
|
||||
@@ -681,6 +704,21 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
}
|
||||
}
|
||||
|
||||
// The filesystem walk knows nothing about versions: drop the ones hidden
|
||||
// behind their original in this view (the recursive branch did it in SQL).
|
||||
if !search_recursively && !include_versions {
|
||||
let hidden = hidden_version_rows(&app_state, library.map(|l| l.id));
|
||||
if !hidden.is_empty() {
|
||||
let (names, libs): (Vec<String>, Vec<i32>) = file_names
|
||||
.into_iter()
|
||||
.zip(file_libraries)
|
||||
.filter(|(name, lib_id)| !hidden.contains(&(*lib_id, name.clone())))
|
||||
.unzip();
|
||||
file_names = names;
|
||||
file_libraries = libs;
|
||||
}
|
||||
}
|
||||
|
||||
if !any_library_resolved {
|
||||
error!("Bad photos request: {}", req.path);
|
||||
span.set_status(Status::error("Invalid path"));
|
||||
@@ -871,16 +909,68 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
span.set_attribute("total_count", total_count.to_string());
|
||||
span.set_status(Status::Ok);
|
||||
|
||||
let (photo_display_paths, photo_version_counts) =
|
||||
version_arrays(&app_state, &response_files, &response_libraries);
|
||||
HttpResponse::Ok().json(PhotosResponse {
|
||||
photos: response_files,
|
||||
dirs,
|
||||
photo_libraries: response_libraries,
|
||||
photo_display_paths,
|
||||
photo_version_counts,
|
||||
total_count: pagination_metadata.0,
|
||||
has_more: pagination_metadata.1,
|
||||
next_offset: pagination_metadata.2,
|
||||
})
|
||||
}
|
||||
|
||||
/// `(library_id, rel_path)` of the version rows hidden in the request's view
|
||||
/// (`library` = the resolved `?library=`; `None` = every library). A lookup
|
||||
/// failure hides nothing: better a duplicate tile than a missing photo.
|
||||
fn hidden_version_rows(
|
||||
app_state: &AppState,
|
||||
library: Option<i32>,
|
||||
) -> std::collections::HashSet<(i32, String)> {
|
||||
let view: Vec<i32> = library.into_iter().collect();
|
||||
app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.hidden_version_rows(&view)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("hidden_version_rows failed: {:?}", e);
|
||||
Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The `/photos` parallel arrays for a page: `photo_display_paths` (the file
|
||||
/// to show when the global choice isn't the original) and
|
||||
/// `photo_version_counts`. Each is empty, so skipped on the wire, when no
|
||||
/// photo on the page has anything to report.
|
||||
fn version_arrays(
|
||||
app_state: &AppState,
|
||||
photos: &[String],
|
||||
libraries: &[i32],
|
||||
) -> (Vec<Option<String>>, Vec<u32>) {
|
||||
let summaries = crate::versions::load_summaries(&app_state.version_dao);
|
||||
if summaries.is_empty() {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
let mut display: Vec<Option<String>> = Vec::with_capacity(photos.len());
|
||||
let mut counts: Vec<u32> = Vec::with_capacity(photos.len());
|
||||
for (path, lib) in photos.iter().zip(libraries) {
|
||||
let s = summaries.get(&(*lib, path.clone()));
|
||||
display.push(s.and_then(|s| s.display.as_ref().map(|(_, p)| p.clone())));
|
||||
counts.push(s.map_or(0, |s| s.version_count));
|
||||
}
|
||||
if display.iter().all(Option::is_none) {
|
||||
display.clear();
|
||||
}
|
||||
if counts.iter().all(|c| *c == 0) {
|
||||
counts.clear();
|
||||
}
|
||||
(display, counts)
|
||||
}
|
||||
|
||||
fn sort(
|
||||
files: Vec<FileWithTagCount>,
|
||||
file_libraries: Vec<i32>,
|
||||
@@ -1253,13 +1343,20 @@ pub async fn list_exif_summary(
|
||||
None,
|
||||
None,
|
||||
crate::date_view::InstantRange::new(req.date_from, req.date_to, display_zone, server_zone),
|
||||
false,
|
||||
) {
|
||||
Ok(rows) => {
|
||||
let summaries = crate::versions::load_summaries(&app_state.version_dao);
|
||||
let photos: Vec<ExifSummary> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let name = library_names.get(&r.library_id).cloned();
|
||||
ExifSummary::from_row(r, name, display_zone, server_zone)
|
||||
let versions = crate::versions::VersionFields::from_summary(
|
||||
summaries.get(&(r.library_id, r.file_path.clone())),
|
||||
);
|
||||
let mut summary = ExifSummary::from_row(r, name, display_zone, server_zone);
|
||||
summary.versions = versions;
|
||||
summary
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1622,6 +1719,7 @@ mod tests {
|
||||
_: Option<&str>,
|
||||
_: Option<(f64, f64, f64, f64)>,
|
||||
_: Option<crate::date_view::InstantRange>,
|
||||
_include_versions: bool,
|
||||
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -1748,6 +1846,7 @@ mod tests {
|
||||
_conv_hi: i64,
|
||||
_unconv_lo: i64,
|
||||
_unconv_hi: i64,
|
||||
_view_library_ids: &[i32],
|
||||
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -1794,6 +1893,7 @@ mod tests {
|
||||
_library_ids: &[i32],
|
||||
_path_prefix: Option<&str>,
|
||||
_include_duplicates: bool,
|
||||
_include_versions: bool,
|
||||
) -> Result<Vec<(i32, String)>, DbError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ pub async fn favorites(
|
||||
photos: favorites,
|
||||
dirs: Vec::new(),
|
||||
photo_libraries: Vec::new(),
|
||||
photo_display_paths: Vec::new(),
|
||||
photo_version_counts: Vec::new(),
|
||||
total_count: None,
|
||||
has_more: None,
|
||||
next_offset: None,
|
||||
|
||||
@@ -46,6 +46,7 @@ pub mod testhelpers;
|
||||
pub mod thumbnails;
|
||||
pub mod unified_search;
|
||||
pub mod utils;
|
||||
pub mod versions;
|
||||
pub mod video;
|
||||
pub mod watcher;
|
||||
|
||||
|
||||
@@ -282,6 +282,37 @@ pub fn refresh_back_refs(conn: &mut SqliteConnection) -> usize {
|
||||
});
|
||||
total += updated;
|
||||
|
||||
// photo_versions — (library_id, rel_path) of the version file; same
|
||||
// repoint as photo_insights.
|
||||
let updated = sql_query(
|
||||
"UPDATE photo_versions \
|
||||
SET library_id = ( \
|
||||
SELECT ie.library_id FROM image_exif ie \
|
||||
WHERE ie.content_hash = photo_versions.version_hash \
|
||||
ORDER BY ie.id LIMIT 1 \
|
||||
), \
|
||||
rel_path = ( \
|
||||
SELECT ie.rel_path FROM image_exif ie \
|
||||
WHERE ie.content_hash = photo_versions.version_hash \
|
||||
ORDER BY ie.id LIMIT 1 \
|
||||
) \
|
||||
WHERE EXISTS ( \
|
||||
SELECT 1 FROM image_exif ie \
|
||||
WHERE ie.content_hash = photo_versions.version_hash \
|
||||
) \
|
||||
AND NOT EXISTS ( \
|
||||
SELECT 1 FROM image_exif ie \
|
||||
WHERE ie.library_id = photo_versions.library_id \
|
||||
AND ie.rel_path = photo_versions.rel_path \
|
||||
)",
|
||||
)
|
||||
.execute(conn)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("back-ref refresh: photo_versions update failed: {:?}", e);
|
||||
0
|
||||
});
|
||||
total += updated;
|
||||
|
||||
if total > 0 {
|
||||
info!("back-ref refresh: updated {} hash-keyed row(s)", total);
|
||||
}
|
||||
@@ -299,6 +330,8 @@ pub struct GcStats {
|
||||
pub deleted_face_detections: usize,
|
||||
pub deleted_tagged_photo: usize,
|
||||
pub deleted_photo_insights: usize,
|
||||
/// Version links (and display choices) whose version file is gone.
|
||||
pub deleted_photo_versions: usize,
|
||||
/// Hashes dropped from the pending set because they re-appeared
|
||||
/// in image_exif (e.g. user remounted a backup that was briefly
|
||||
/// missing).
|
||||
@@ -311,12 +344,16 @@ impl GcStats {
|
||||
|| self.deleted_face_detections > 0
|
||||
|| self.deleted_tagged_photo > 0
|
||||
|| self.deleted_photo_insights > 0
|
||||
|| self.deleted_photo_versions > 0
|
||||
|| self.revived > 0
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn total_deleted(&self) -> usize {
|
||||
self.deleted_face_detections + self.deleted_tagged_photo + self.deleted_photo_insights
|
||||
self.deleted_face_detections
|
||||
+ self.deleted_tagged_photo
|
||||
+ self.deleted_photo_insights
|
||||
+ self.deleted_photo_versions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,10 +470,11 @@ pub fn run_orphan_gc(
|
||||
|
||||
if !to_delete.is_empty() {
|
||||
match delete_hash_keyed_rows(conn, &to_delete) {
|
||||
Ok((faces, tags, insights)) => {
|
||||
Ok((faces, tags, insights, versions)) => {
|
||||
stats.deleted_face_detections = faces;
|
||||
stats.deleted_tagged_photo = tags;
|
||||
stats.deleted_photo_insights = insights;
|
||||
stats.deleted_photo_versions = versions;
|
||||
// Drop deleted hashes from pending so we don't try to
|
||||
// re-delete them next tick (they'll have already been
|
||||
// removed from the orphan set).
|
||||
@@ -452,12 +490,13 @@ pub fn run_orphan_gc(
|
||||
|
||||
if stats.changed() {
|
||||
info!(
|
||||
"orphan-gc: {} new orphan hash(es) marked, {} revived; deleted {} face_detections / {} tagged_photo / {} photo_insights row(s) (pending: {})",
|
||||
"orphan-gc: {} new orphan hash(es) marked, {} revived; deleted {} face_detections / {} tagged_photo / {} photo_insights / {} photo_versions row(s) (pending: {})",
|
||||
stats.newly_marked,
|
||||
stats.revived,
|
||||
stats.deleted_face_detections,
|
||||
stats.deleted_tagged_photo,
|
||||
stats.deleted_photo_insights,
|
||||
stats.deleted_photo_versions,
|
||||
state.pending.len(),
|
||||
);
|
||||
} else {
|
||||
@@ -503,6 +542,12 @@ fn collect_orphan_hashes(conn: &mut SqliteConnection) -> QueryResult<HashSet<Str
|
||||
SELECT content_hash FROM tagged_photo WHERE content_hash IS NOT NULL \
|
||||
UNION ALL \
|
||||
SELECT content_hash FROM photo_insights WHERE content_hash IS NOT NULL \
|
||||
UNION ALL \
|
||||
SELECT version_hash FROM photo_versions \
|
||||
UNION ALL \
|
||||
SELECT display_hash FROM photo_version_display \
|
||||
UNION ALL \
|
||||
SELECT original_hash FROM photo_version_display \
|
||||
) AS derived \
|
||||
WHERE content_hash NOT IN ( \
|
||||
SELECT content_hash FROM image_exif WHERE content_hash IS NOT NULL \
|
||||
@@ -514,13 +559,19 @@ fn collect_orphan_hashes(conn: &mut SqliteConnection) -> QueryResult<HashSet<Str
|
||||
}
|
||||
|
||||
/// Delete every hash-keyed row whose `content_hash` is in `hashes`.
|
||||
/// Returns `(faces, tagged_photo, photo_insights)`.
|
||||
/// Returns `(faces, tagged_photo, photo_insights, photo_versions)`.
|
||||
///
|
||||
/// For photo versions (#191): a link whose *version* file is gone, its
|
||||
/// reference rows, and any display choice naming the gone file (reads fall
|
||||
/// back to the original until then) or the gone original. A link whose
|
||||
/// *original* is gone is kept: the version shows as a standalone photo and
|
||||
/// the link revives if the original returns.
|
||||
fn delete_hash_keyed_rows(
|
||||
conn: &mut SqliteConnection,
|
||||
hashes: &[String],
|
||||
) -> QueryResult<(usize, usize, usize)> {
|
||||
) -> QueryResult<(usize, usize, usize, usize)> {
|
||||
if hashes.is_empty() {
|
||||
return Ok((0, 0, 0));
|
||||
return Ok((0, 0, 0, 0));
|
||||
}
|
||||
|
||||
use crate::database::schema::{face_detections, photo_insights, tagged_photo};
|
||||
@@ -535,7 +586,27 @@ fn delete_hash_keyed_rows(
|
||||
diesel::delete(photo_insights::table.filter(photo_insights::content_hash.eq_any(hashes)))
|
||||
.execute(conn)?;
|
||||
|
||||
Ok((faces, tags, insights))
|
||||
use crate::database::schema::{
|
||||
photo_version_display, photo_version_references, photo_versions,
|
||||
};
|
||||
let versions =
|
||||
diesel::delete(photo_versions::table.filter(photo_versions::version_hash.eq_any(hashes)))
|
||||
.execute(conn)?;
|
||||
diesel::delete(
|
||||
photo_version_references::table
|
||||
.filter(photo_version_references::version_hash.eq_any(hashes)),
|
||||
)
|
||||
.execute(conn)?;
|
||||
let displays = diesel::delete(
|
||||
photo_version_display::table.filter(
|
||||
photo_version_display::display_hash
|
||||
.eq_any(hashes)
|
||||
.or(photo_version_display::original_hash.eq_any(hashes)),
|
||||
),
|
||||
)
|
||||
.execute(conn)?;
|
||||
|
||||
Ok((faces, tags, insights, versions + displays))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -326,6 +326,8 @@ fn main() -> std::io::Result<()> {
|
||||
.service(handlers::favorites::favorites)
|
||||
.service(handlers::favorites::put_add_favorite)
|
||||
.service(handlers::favorites::delete_favorite)
|
||||
.service(versions::get_versions)
|
||||
.service(versions::put_display)
|
||||
.service(handlers::image::get_file_metadata)
|
||||
.service(handlers::image::set_image_gps)
|
||||
.service(handlers::image::set_image_date)
|
||||
|
||||
+23
-1
@@ -172,6 +172,10 @@ pub struct MemoryItem {
|
||||
/// the camera's clock, which can differ from the owner's day that
|
||||
/// `created` groups under. Render verbatim (ADR-0001).
|
||||
pub wall_clock: Option<String>,
|
||||
/// `display_path` / `version_count` (#191); absent for a photo without
|
||||
/// versions. The item's `path` is always the original's.
|
||||
#[serde(flatten)]
|
||||
pub versions: crate::versions::VersionFields,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -652,6 +656,11 @@ pub fn gather_memory_items(
|
||||
// memory in the client's tz, used as the primary sort key.
|
||||
let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = Vec::new();
|
||||
|
||||
// A photo version is left out of the memories while its original is
|
||||
// reachable in this view (the selected library, else every library).
|
||||
let view_library_ids: Vec<i32> = library.map(|l| vec![l.id]).unwrap_or_default();
|
||||
let version_summaries = crate::versions::load_summaries(&app_state.version_dao);
|
||||
|
||||
for lib in &libraries_to_scan {
|
||||
let base = Path::new(&lib.root_path);
|
||||
let effective = lib.effective_excluded_dirs(&app_state.excluded_dirs);
|
||||
@@ -659,7 +668,14 @@ pub fn gather_memory_items(
|
||||
|
||||
let rows = match exif_dao.lock() {
|
||||
Ok(mut dao) => {
|
||||
match dao.get_memories_in_window(lib.id, conv_lo, conv_hi, unconv_lo, unconv_hi) {
|
||||
match dao.get_memories_in_window(
|
||||
lib.id,
|
||||
conv_lo,
|
||||
conv_hi,
|
||||
unconv_lo,
|
||||
unconv_hi,
|
||||
&view_library_ids,
|
||||
) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -697,6 +713,9 @@ pub fn gather_memory_items(
|
||||
|
||||
let created = crate::date_view::row_true_instant(&row, display_zone, server_zone);
|
||||
let wall_clock = crate::date_view::row_wall_clock(&row, display_zone, server_zone);
|
||||
let versions = crate::versions::VersionFields::from_summary(
|
||||
version_summaries.get(&(lib.id, row.file_path.clone())),
|
||||
);
|
||||
memories_with_dates.push((
|
||||
MemoryItem {
|
||||
path: row.file_path,
|
||||
@@ -707,6 +726,7 @@ pub fn gather_memory_items(
|
||||
camera_offset_minutes: row.camera_offset_minutes,
|
||||
date_taken_tz_source: row.date_taken_tz_source,
|
||||
wall_clock,
|
||||
versions,
|
||||
},
|
||||
display_day,
|
||||
));
|
||||
@@ -1228,6 +1248,7 @@ mod tests {
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
wall_clock: None,
|
||||
versions: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1746,6 +1767,7 @@ mod tests {
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
wall_clock: None,
|
||||
versions: Default::default(),
|
||||
};
|
||||
let v: serde_json::Value =
|
||||
serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap();
|
||||
|
||||
@@ -433,6 +433,7 @@ mod tests {
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
wall_clock: None,
|
||||
versions: Default::default(),
|
||||
},
|
||||
memories::MemoryItem {
|
||||
path: "b".into(),
|
||||
@@ -443,6 +444,7 @@ mod tests {
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
wall_clock: None,
|
||||
versions: Default::default(),
|
||||
},
|
||||
memories::MemoryItem {
|
||||
path: "c".into(),
|
||||
@@ -453,6 +455,7 @@ mod tests {
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
wall_clock: None,
|
||||
versions: Default::default(),
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
@@ -472,6 +475,7 @@ mod tests {
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
wall_clock: None,
|
||||
versions: Default::default(),
|
||||
}
|
||||
}
|
||||
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
|
||||
|
||||
+12
-1
@@ -13,7 +13,7 @@ use crate::database::{
|
||||
LocationHistoryDao, PrecomputedReelDao, SearchHistoryDao, SqliteCalendarEventDao,
|
||||
SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao, SqliteInsightGenerationJobDao,
|
||||
SqliteKnowledgeDao, SqliteLocationHistoryDao, SqlitePrecomputedReelDao, SqliteSearchHistoryDao,
|
||||
SqliteUserAiPrefsDao, UserAiPrefsDao, connect,
|
||||
SqliteUserAiPrefsDao, SqliteVersionDao, UserAiPrefsDao, VersionDao, connect,
|
||||
};
|
||||
use crate::database::{PreviewDao, SqlitePreviewDao};
|
||||
use crate::faces;
|
||||
@@ -98,6 +98,10 @@ pub struct AppState {
|
||||
/// client; read by the nightly pre-generation scheduler.
|
||||
#[allow(dead_code)]
|
||||
pub user_ai_prefs_dao: Arc<Mutex<Box<dyn UserAiPrefsDao>>>,
|
||||
/// Photo-version links and the global display choice (#191). Read by
|
||||
/// the listings that annotate a photo with its versions and by the
|
||||
/// `/image/versions` endpoints.
|
||||
pub version_dao: Arc<Mutex<Box<dyn VersionDao>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -142,6 +146,7 @@ impl AppState {
|
||||
insight_job_handles: Arc<Mutex<HashMap<i32, tokio::task::AbortHandle>>>,
|
||||
precomputed_reel_dao: Arc<Mutex<Box<dyn PrecomputedReelDao>>>,
|
||||
user_ai_prefs_dao: Arc<Mutex<Box<dyn UserAiPrefsDao>>>,
|
||||
version_dao: Arc<Mutex<Box<dyn VersionDao>>>,
|
||||
) -> Self {
|
||||
assert!(
|
||||
!libraries_vec.is_empty(),
|
||||
@@ -202,6 +207,7 @@ impl AppState {
|
||||
insight_job_handles,
|
||||
precomputed_reel_dao,
|
||||
user_ai_prefs_dao,
|
||||
version_dao,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +296,9 @@ impl Default for AppState {
|
||||
let user_ai_prefs_dao: Arc<Mutex<Box<dyn UserAiPrefsDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new())));
|
||||
|
||||
let version_dao: Arc<Mutex<Box<dyn VersionDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteVersionDao::new())));
|
||||
|
||||
// Load base path and ensure the primary library row reflects it.
|
||||
let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env");
|
||||
let mut seed_conn = connect();
|
||||
@@ -374,6 +383,7 @@ impl Default for AppState {
|
||||
insight_job_handles,
|
||||
precomputed_reel_dao,
|
||||
user_ai_prefs_dao,
|
||||
version_dao,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -552,6 +562,7 @@ impl AppState {
|
||||
Arc::new(Mutex::new(HashMap::new())), // placeholder for test
|
||||
Arc::new(Mutex::new(Box::new(SqlitePrecomputedReelDao::new()))), // placeholder for test
|
||||
Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new()))), // placeholder for test
|
||||
Arc::new(Mutex::new(Box::new(SqliteVersionDao::new()))), // placeholder for test
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -604,6 +604,16 @@ pub trait TagDao: Send + Sync {
|
||||
fn delete_tag(&mut self, id: i32) -> anyhow::Result<bool>;
|
||||
}
|
||||
|
||||
/// The path a tag read/write on `path` applies to: the original's when
|
||||
/// `path` is a linked photo version whose original is still indexed, else
|
||||
/// `path` itself.
|
||||
fn canonical_path(conn: &mut SqliteConnection, path: &str) -> String {
|
||||
crate::database::version_dao::canonical_rel_path(conn, path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
pub struct SqliteTagDao {
|
||||
connection: Arc<Mutex<SqliteConnection>>,
|
||||
}
|
||||
@@ -670,6 +680,8 @@ impl TagDao for SqliteTagDao {
|
||||
span.set_attribute("path", path.to_string());
|
||||
|
||||
debug!("Getting Tags for path: {:?}", path);
|
||||
// A photo version carries its original's tags.
|
||||
let path = &canonical_path(conn.deref_mut(), path);
|
||||
tags::table
|
||||
.left_join(tagged_photo::table)
|
||||
.filter(tagged_photo::rel_path.eq(&path))
|
||||
@@ -861,6 +873,7 @@ impl TagDao for SqliteTagDao {
|
||||
span.set_attribute("tag_name", tag_name.to_string());
|
||||
span.set_attribute("path", path.to_string());
|
||||
|
||||
let path = &canonical_path(conn.deref_mut(), path);
|
||||
tags::table
|
||||
.filter(tags::name.eq(tag_name))
|
||||
.get_result::<Tag>(conn.deref_mut())
|
||||
@@ -893,6 +906,10 @@ impl TagDao for SqliteTagDao {
|
||||
span.set_attribute("path", path.to_string());
|
||||
span.set_attribute("tag_id", tag_id.to_string());
|
||||
|
||||
// Tagging a photo version tags its original (photo versions,
|
||||
// #191): the tag binds to the original's bytes and path.
|
||||
let path = &canonical_path(conn.deref_mut(), path);
|
||||
|
||||
// Eagerly populate content_hash so this tag follows the bytes,
|
||||
// not the path (see CLAUDE.md "Multi-library data model").
|
||||
// None is fine — the reconciliation pass will backfill once
|
||||
|
||||
+43
-2
@@ -343,9 +343,36 @@ pub fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String])
|
||||
}
|
||||
|
||||
pub fn update_media_counts(media_dir: &Path, excluded_dirs: &[String]) {
|
||||
update_media_counts_excluding(media_dir, excluded_dirs, &std::collections::HashSet::new());
|
||||
}
|
||||
|
||||
/// As [`update_media_counts`], leaving out the files in `hidden` (rel paths
|
||||
/// under `media_dir`): photo versions (#191) are the same photo as their
|
||||
/// original, so the gauges count it once.
|
||||
pub fn update_media_counts_excluding(
|
||||
media_dir: &Path,
|
||||
excluded_dirs: &[String],
|
||||
hidden: &std::collections::HashSet<String>,
|
||||
) {
|
||||
let (image_count, video_count) = count_media(media_dir, excluded_dirs, hidden);
|
||||
IMAGE_GAUGE.set(image_count);
|
||||
VIDEO_GAUGE.set(video_count);
|
||||
}
|
||||
|
||||
fn count_media(
|
||||
media_dir: &Path,
|
||||
excluded_dirs: &[String],
|
||||
hidden: &std::collections::HashSet<String>,
|
||||
) -> (i64, i64) {
|
||||
let mut image_count = 0;
|
||||
let mut video_count = 0;
|
||||
for entry in crate::file_scan::walk_library_files(media_dir, excluded_dirs) {
|
||||
if !hidden.is_empty()
|
||||
&& let Ok(rel) = entry.path().strip_prefix(media_dir)
|
||||
&& hidden.contains(&rel.to_string_lossy().replace('\\', "/"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if is_image(&entry) {
|
||||
image_count += 1;
|
||||
} else if is_video(&entry) {
|
||||
@@ -353,8 +380,7 @@ pub fn update_media_counts(media_dir: &Path, excluded_dirs: &[String]) {
|
||||
}
|
||||
}
|
||||
|
||||
IMAGE_GAUGE.set(image_count);
|
||||
VIDEO_GAUGE.set(video_count);
|
||||
(image_count, video_count)
|
||||
}
|
||||
|
||||
pub fn is_image(entry: &DirEntry) -> bool {
|
||||
@@ -395,6 +421,21 @@ mod tests {
|
||||
assert_eq!(s, PathBuf::from("/thumbs/notes.unsupported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_media_counts_leaves_out_hidden_versions() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
fs::create_dir(tmp.path().join("aiedits")).unwrap();
|
||||
fs::write(tmp.path().join("a.jpg"), b"").unwrap();
|
||||
fs::write(tmp.path().join("aiedits/a.jpg.restored.png"), b"").unwrap();
|
||||
let hidden = std::collections::HashSet::from(["aiedits/a.jpg.restored.png".to_string()]);
|
||||
assert_eq!(count_media(tmp.path(), &[], &hidden), (1, 0));
|
||||
assert_eq!(
|
||||
count_media(tmp.path(), &[], &Default::default()),
|
||||
(2, 0),
|
||||
"nothing hidden: both files count"
|
||||
);
|
||||
}
|
||||
|
||||
/// Smoke-test update_media_counts: build a tempdir with two images
|
||||
/// and one video, run the walker, and assert the gauges line up.
|
||||
/// Exercises the is_image / is_video classifier on real DirEntry
|
||||
|
||||
+18
-1
@@ -459,12 +459,21 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
display_zone,
|
||||
server_zone,
|
||||
),
|
||||
// Versions are dropped below, against the request's own
|
||||
// library scope (this query is unscoped).
|
||||
true,
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("unified_search: query_by_exif failed: {e:?}");
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
let hidden_versions = state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("version dao")
|
||||
.hidden_version_rows(&library_ids)
|
||||
.unwrap_or_default();
|
||||
|
||||
candidate = rows
|
||||
.into_iter()
|
||||
@@ -473,6 +482,10 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
if !library_ids.is_empty() && !library_ids.contains(&row.library_id) {
|
||||
return false;
|
||||
}
|
||||
// A version stays behind its original.
|
||||
if hidden_versions.contains(&(row.library_id, row.file_path.clone())) {
|
||||
return false;
|
||||
}
|
||||
// Precise GPS distance (the EXIF query only did a coarse box).
|
||||
if let Some((lat, lon, radius_km)) = gps {
|
||||
match (row.gps_latitude, row.gps_longitude) {
|
||||
@@ -569,7 +582,7 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
);
|
||||
let total_matching = hits.len();
|
||||
let page = paginate(&hits, offset, limit);
|
||||
let results = resolve_hits(&exif_dao, &page);
|
||||
let results = resolve_hits(&exif_dao, &state.version_dao, &page);
|
||||
HttpResponse::Ok().json(UnifiedResponse {
|
||||
query: nl,
|
||||
interpreted: interpreted(&echo, resolved_place),
|
||||
@@ -593,6 +606,7 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
let results: Vec<SearchHit> = if offset >= total_matching {
|
||||
Vec::new()
|
||||
} else {
|
||||
let summaries = crate::versions::load_summaries(&state.version_dao);
|
||||
candidate[offset..end]
|
||||
.iter()
|
||||
.map(|r| SearchHit {
|
||||
@@ -600,6 +614,9 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
rel_path: r.file_path.clone(),
|
||||
content_hash: r.content_hash.clone().unwrap_or_default(),
|
||||
score: 0.0,
|
||||
versions: crate::versions::VersionFields::from_summary(
|
||||
summaries.get(&(r.library_id, r.file_path.clone())),
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,979 @@
|
||||
//! Backfill of photo versions that were ingested before linking existed
|
||||
//! (the `link_versions` binary).
|
||||
//!
|
||||
//! Two kinds of proposal:
|
||||
//!
|
||||
//! - **Exact** (`xmp`, `name`): the PNG carries the producer's XMP
|
||||
//! `DerivedFrom` hash, or its name follows `<name>.<kind>[.<n>].<ext>` next
|
||||
//! to an indexed original. The ingest path would have made the same link,
|
||||
//! so these apply without review.
|
||||
//! - **Review** (`comfy-loader`, `name-prefix`, `phash`): files with no XMP,
|
||||
//! matched by the ComfyUI prompt's `LoadImage` input, an Upscayl-style
|
||||
//! name, or the nearest pHash. Heuristic, so they only go through a TSV a
|
||||
//! human has read (`--reviewed`). Ambiguous or unresolvable cases are
|
||||
//! listed, never guessed.
|
||||
//!
|
||||
//! [`plan`] only reads (database rows handed in, library files for XMP and
|
||||
//! the prompt chunk); [`apply`] is the only writer and goes through
|
||||
//! [`VersionDao`].
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use super::xmp::{self, XMP_KEYWORD};
|
||||
use super::{VERSIONS_SUBFOLDER, original_candidates, parse_version_name};
|
||||
use crate::database::version_dao::{
|
||||
LinkOutcome, NewVersionLink, SOURCE_BACKFILL, SOURCE_NAME, SOURCE_XMP, VersionDao,
|
||||
is_known_kind,
|
||||
};
|
||||
use crate::file_types;
|
||||
use crate::libraries::Library;
|
||||
|
||||
/// A nearest-pHash match farther than this (of 64 bits) is not proposed.
|
||||
pub const MAX_PHASH_DISTANCE: u32 = 16;
|
||||
|
||||
/// One `image_exif` row, as far as planning needs it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileRow {
|
||||
pub library_id: i32,
|
||||
pub rel_path: String,
|
||||
pub content_hash: String,
|
||||
pub phash: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
/// Applies without review.
|
||||
Exact,
|
||||
/// Heuristic; applies only from a reviewed TSV.
|
||||
Review,
|
||||
/// Listed, not guessed.
|
||||
Unresolved,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Status::Exact => "exact",
|
||||
Status::Review => "review",
|
||||
Status::Unresolved => "unresolved",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Proposal {
|
||||
pub status: Status,
|
||||
/// `xmp` | `name` | `comfy-loader` | `name-prefix` | `phash`
|
||||
pub method: &'static str,
|
||||
pub library_id: i32,
|
||||
pub version_path: String,
|
||||
pub version_hash: String,
|
||||
/// Empty for an unresolved case, or an XMP link whose original isn't indexed.
|
||||
pub original_path: String,
|
||||
pub original_hash: String,
|
||||
pub kind: String,
|
||||
pub distance: Option<u32>,
|
||||
/// Hashes of reference photos (the other loader inputs).
|
||||
pub references: Vec<String>,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
pub const TSV_HEADER: &str = "status\tmethod\tlibrary_id\tversion_path\toriginal_path\tphash_distance\tkind\tversion_hash\toriginal_hash\treferences\tnotes";
|
||||
|
||||
impl Proposal {
|
||||
pub fn to_tsv(&self) -> String {
|
||||
let clean = |s: &str| s.replace(['\t', '\n', '\r'], " ");
|
||||
[
|
||||
self.status.as_str().to_string(),
|
||||
self.method.to_string(),
|
||||
self.library_id.to_string(),
|
||||
clean(&self.version_path),
|
||||
clean(&self.original_path),
|
||||
self.distance.map(|d| d.to_string()).unwrap_or_default(),
|
||||
self.kind.clone(),
|
||||
self.version_hash.clone(),
|
||||
self.original_hash.clone(),
|
||||
self.references.join(","),
|
||||
clean(&self.notes),
|
||||
]
|
||||
.join("\t")
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of a reviewed TSV: just enough to link.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReviewedLink {
|
||||
pub method: String,
|
||||
pub library_id: i32,
|
||||
pub version_path: String,
|
||||
pub kind: String,
|
||||
pub version_hash: String,
|
||||
pub original_hash: String,
|
||||
pub references: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse a reviewed TSV (the dry-run output with rows deleted or kept).
|
||||
/// Rows that aren't `review` status with both hashes are skipped; so are the
|
||||
/// header, blanks and `#` comments. Returns `(links, skipped_lines)`.
|
||||
pub fn parse_reviewed(tsv: &str) -> (Vec<ReviewedLink>, usize) {
|
||||
let mut out = Vec::new();
|
||||
let mut skipped = 0;
|
||||
for line in tsv.lines() {
|
||||
if line.trim().is_empty() || line.starts_with('#') || line.starts_with("status\t") {
|
||||
continue;
|
||||
}
|
||||
let f: Vec<&str> = line.split('\t').collect();
|
||||
let usable = f.len() >= 10
|
||||
&& f[0] == "review"
|
||||
&& f[2].parse::<i32>().is_ok()
|
||||
&& !f[7].is_empty()
|
||||
&& !f[8].is_empty();
|
||||
if !usable {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
out.push(ReviewedLink {
|
||||
method: f[1].to_string(),
|
||||
library_id: f[2].parse().unwrap(),
|
||||
version_path: f[3].to_string(),
|
||||
kind: f[6].to_string(),
|
||||
version_hash: f[7].to_string(),
|
||||
original_hash: f[8].to_string(),
|
||||
references: f[9]
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
(out, skipped)
|
||||
}
|
||||
|
||||
fn dir_of(rel: &str) -> &str {
|
||||
rel.rsplit_once('/').map_or("", |(d, _)| d)
|
||||
}
|
||||
|
||||
fn name_of(rel: &str) -> &str {
|
||||
rel.rsplit('/').next().unwrap_or(rel)
|
||||
}
|
||||
|
||||
fn parent_of_dir(dir: &str) -> &str {
|
||||
dir.rsplit_once('/').map_or("", |(p, _)| p)
|
||||
}
|
||||
|
||||
fn is_aiedits_dir(dir: &str) -> bool {
|
||||
name_of(dir).eq_ignore_ascii_case(VERSIONS_SUBFOLDER)
|
||||
}
|
||||
|
||||
/// Names that are themselves versions (convention or Upscayl output) are
|
||||
/// never proposed as someone's original.
|
||||
fn looks_like_a_version(file_name: &str) -> bool {
|
||||
parse_version_name(file_name).is_some() || upscayl_stem(file_name).is_some()
|
||||
}
|
||||
|
||||
fn hamming(a: i64, b: i64) -> u32 {
|
||||
((a ^ b) as u64).count_ones()
|
||||
}
|
||||
|
||||
static UPSCAYL_SUFFIX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
// `<name>-4x.png`, `<name>_upscayl_2x_ultramix_balanced.png`
|
||||
Regex::new(r"(?i)^(?P<stem>.+?)(?:-\d+x|_upscayl_\d+(?:\.\d+)?x_[^.]+)\.png$").unwrap()
|
||||
});
|
||||
|
||||
fn upscayl_stem(file_name: &str) -> Option<String> {
|
||||
UPSCAYL_SUFFIX
|
||||
.captures(file_name)
|
||||
.map(|c| c["stem"].to_string())
|
||||
}
|
||||
|
||||
/// File names a ComfyUI prompt loads, in node order: the `image` input of
|
||||
/// every `LoadImage*` node.
|
||||
pub fn loader_names(prompt_json: &str) -> Vec<String> {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(prompt_json) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(nodes) = value.as_object() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut ids: Vec<&String> = nodes.keys().collect();
|
||||
ids.sort_by_key(|k| k.parse::<u64>().unwrap_or(u64::MAX));
|
||||
let mut out = Vec::new();
|
||||
for id in ids {
|
||||
let node = &nodes[id];
|
||||
let class = node["class_type"].as_str().unwrap_or("");
|
||||
if !class.starts_with("LoadImage") {
|
||||
continue;
|
||||
}
|
||||
if let Some(raw) = node["inputs"]["image"].as_str() {
|
||||
let name = raw.trim_end_matches(" [input]");
|
||||
let name = name.rsplit(['/', '\\']).next().unwrap_or(name);
|
||||
if !name.is_empty() {
|
||||
out.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct Index<'a> {
|
||||
by_path: HashMap<(i32, &'a str), &'a FileRow>,
|
||||
by_name: HashMap<(i32, &'a str), Vec<&'a FileRow>>,
|
||||
by_dir: HashMap<(i32, &'a str), Vec<&'a FileRow>>,
|
||||
}
|
||||
|
||||
impl<'a> Index<'a> {
|
||||
fn new(rows: &'a [FileRow]) -> Self {
|
||||
let mut idx = Index {
|
||||
by_path: HashMap::new(),
|
||||
by_name: HashMap::new(),
|
||||
by_dir: HashMap::new(),
|
||||
};
|
||||
for r in rows {
|
||||
idx.by_path.insert((r.library_id, r.rel_path.as_str()), r);
|
||||
idx.by_name
|
||||
.entry((r.library_id, name_of(&r.rel_path)))
|
||||
.or_default()
|
||||
.push(r);
|
||||
idx.by_dir
|
||||
.entry((r.library_id, dir_of(&r.rel_path)))
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
idx
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan every link the backfill can propose. `rows` are all hashed image
|
||||
/// rows; `linked` the version hashes that already have a link.
|
||||
pub fn plan(libs: &[Library], rows: &[FileRow], linked: &HashSet<String>) -> Vec<Proposal> {
|
||||
let idx = Index::new(rows);
|
||||
let root_of: HashMap<i32, &str> = libs.iter().map(|l| (l.id, l.root_path.as_str())).collect();
|
||||
let mut out = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
if linked.contains(&row.content_hash) {
|
||||
continue;
|
||||
}
|
||||
let name = name_of(&row.rel_path);
|
||||
let dir = dir_of(&row.rel_path);
|
||||
let is_png = name.to_ascii_lowercase().ends_with(".png");
|
||||
let name_conv = parse_version_name(name);
|
||||
let upscayl = if is_png { upscayl_stem(name) } else { None };
|
||||
let in_aiedits = is_aiedits_dir(dir);
|
||||
if !(is_png || name_conv.is_some()) {
|
||||
continue;
|
||||
}
|
||||
if !file_types::is_image_file(Path::new(&row.rel_path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Text chunks (XMP + ComfyUI prompt) of PNGs, read once.
|
||||
let mut xmp_text = None;
|
||||
let mut prompt = None;
|
||||
if is_png && let Some(root) = root_of.get(&row.library_id) {
|
||||
let abs = Path::new(root).join(&row.rel_path);
|
||||
if let Ok(chunks) = xmp::read_png_text(&abs, &[XMP_KEYWORD, "prompt"]) {
|
||||
for (k, v) in chunks {
|
||||
if k == XMP_KEYWORD {
|
||||
xmp_text = Some(v);
|
||||
} else {
|
||||
prompt = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. XMP: exact, even when the original isn't indexed yet.
|
||||
if let Some(x) = xmp_text.as_deref().and_then(xmp::parse_version_xmp)
|
||||
&& x.original_hash != row.content_hash
|
||||
{
|
||||
let original = rows.iter().find(|r| r.content_hash == x.original_hash);
|
||||
let kind = x
|
||||
.kind
|
||||
.filter(|k| is_known_kind(k))
|
||||
.or_else(|| name_conv.as_ref().map(|n| n.kind.to_string()))
|
||||
.unwrap_or_else(|| "edit".to_string());
|
||||
out.push(Proposal {
|
||||
status: Status::Exact,
|
||||
method: "xmp",
|
||||
library_id: row.library_id,
|
||||
version_path: row.rel_path.clone(),
|
||||
version_hash: row.content_hash.clone(),
|
||||
original_path: original.map(|o| o.rel_path.clone()).unwrap_or_default(),
|
||||
original_hash: x.original_hash,
|
||||
kind,
|
||||
distance: distance(row, original),
|
||||
references: x.references,
|
||||
notes: if original.is_some() {
|
||||
String::new()
|
||||
} else {
|
||||
"original not indexed (yet)".into()
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Name convention next to an indexed original: exact.
|
||||
if let Some(n) = &name_conv {
|
||||
let found = original_candidates(&row.rel_path)
|
||||
.into_iter()
|
||||
.find_map(|c| {
|
||||
idx.by_path
|
||||
.get(&(row.library_id, c.as_str()))
|
||||
.copied()
|
||||
.filter(|o| o.content_hash != row.content_hash)
|
||||
});
|
||||
if let Some(o) = found {
|
||||
out.push(Proposal {
|
||||
status: Status::Exact,
|
||||
method: "name",
|
||||
library_id: row.library_id,
|
||||
version_path: row.rel_path.clone(),
|
||||
version_hash: row.content_hash.clone(),
|
||||
original_path: o.rel_path.clone(),
|
||||
original_hash: o.content_hash.clone(),
|
||||
kind: n.kind.to_string(),
|
||||
distance: distance(row, Some(o)),
|
||||
references: Vec::new(),
|
||||
notes: String::new(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A convention name with no original found falls through to the
|
||||
// heuristics only if it is also a ComfyUI output in aiedits/.
|
||||
if !in_aiedits {
|
||||
out.push(unresolved(row, "name", "no original with that name nearby"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Heuristics (PNG only; reviewed before applying).
|
||||
if !is_png {
|
||||
continue;
|
||||
}
|
||||
let loaders = prompt.as_deref().map(loader_names).unwrap_or_default();
|
||||
if in_aiedits && !loaders.is_empty() {
|
||||
out.push(plan_comfy_loader(row, &loaders, &idx));
|
||||
} else if let Some(stem) = upscayl {
|
||||
out.push(plan_prefix(row, &stem, &idx));
|
||||
} else if in_aiedits {
|
||||
out.push(plan_phash(row, rows));
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| {
|
||||
(a.status as u8, a.library_id, &a.version_path).cmp(&(
|
||||
b.status as u8,
|
||||
b.library_id,
|
||||
&b.version_path,
|
||||
))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn distance(version: &FileRow, original: Option<&FileRow>) -> Option<u32> {
|
||||
Some(hamming(version.phash?, original?.phash?))
|
||||
}
|
||||
|
||||
fn unresolved(row: &FileRow, method: &'static str, why: &str) -> Proposal {
|
||||
Proposal {
|
||||
status: Status::Unresolved,
|
||||
method,
|
||||
library_id: row.library_id,
|
||||
version_path: row.rel_path.clone(),
|
||||
version_hash: row.content_hash.clone(),
|
||||
original_path: String::new(),
|
||||
original_hash: String::new(),
|
||||
kind: "edit".into(),
|
||||
distance: None,
|
||||
references: Vec::new(),
|
||||
notes: why.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest row to `row` by pHash among `candidates` (needs both hashes).
|
||||
fn nearest<'a>(row: &FileRow, candidates: &[&'a FileRow]) -> Option<(&'a FileRow, u32)> {
|
||||
let p = row.phash?;
|
||||
candidates
|
||||
.iter()
|
||||
.filter_map(|c| Some((*c, hamming(p, c.phash?))))
|
||||
.min_by_key(|(c, d)| (*d, c.rel_path.clone()))
|
||||
}
|
||||
|
||||
fn plan_comfy_loader(row: &FileRow, loaders: &[String], idx: &Index) -> Proposal {
|
||||
let dir = dir_of(&row.rel_path);
|
||||
let parent = parent_of_dir(dir);
|
||||
// Per loader name: the matching rows from the first tier that has any,
|
||||
// preferring the parent of aiedits/, then the same folder, then anywhere
|
||||
// in the library. Version-looking files never count as originals.
|
||||
let resolve = |loader: &str| -> Vec<&FileRow> {
|
||||
let all: Vec<&FileRow> = idx
|
||||
.by_name
|
||||
.get(&(row.library_id, loader))
|
||||
.map(|v| {
|
||||
v.iter()
|
||||
.copied()
|
||||
.filter(|r| {
|
||||
r.content_hash != row.content_hash && !is_aiedits_dir(dir_of(&r.rel_path))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for tier in [parent, dir] {
|
||||
let hit: Vec<&FileRow> = all
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|r| dir_of(&r.rel_path) == tier)
|
||||
.collect();
|
||||
if !hit.is_empty() {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
all
|
||||
};
|
||||
|
||||
let mut resolved: Vec<(&String, Vec<&FileRow>)> = Vec::new();
|
||||
let mut missing: Vec<&String> = Vec::new();
|
||||
for l in loaders {
|
||||
let hits = resolve(l);
|
||||
if hits.is_empty() {
|
||||
missing.push(l);
|
||||
} else {
|
||||
resolved.push((l, hits));
|
||||
}
|
||||
}
|
||||
if resolved.is_empty() {
|
||||
let mut p = unresolved(
|
||||
row,
|
||||
"comfy-loader",
|
||||
&format!(
|
||||
"loader input(s) not found in the library: {}",
|
||||
loaders.join(", ")
|
||||
),
|
||||
);
|
||||
// A hint for the reviewer, not a proposal.
|
||||
let siblings: Vec<&FileRow> = idx
|
||||
.by_dir
|
||||
.get(&(row.library_id, parent))
|
||||
.map(|v| {
|
||||
v.iter()
|
||||
.copied()
|
||||
.filter(|r| !is_aiedits_dir(dir_of(&r.rel_path)))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some((n, d)) = nearest(row, &siblings) {
|
||||
p.notes
|
||||
.push_str(&format!("; hint: nearest pHash {} (d={d})", n.rel_path));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// Primary: the nearest pHash across everything the loaders resolved to.
|
||||
let flat: Vec<(&String, &FileRow)> = resolved
|
||||
.iter()
|
||||
.flat_map(|(l, hits)| hits.iter().map(move |h| (*l, *h)))
|
||||
.collect();
|
||||
let rows_only: Vec<&FileRow> = flat.iter().map(|(_, r)| *r).collect();
|
||||
let (primary, dist) = match nearest(row, &rows_only) {
|
||||
Some((p, d)) => (p, Some(d)),
|
||||
None => (rows_only[0], None),
|
||||
};
|
||||
let mut notes = Vec::new();
|
||||
if resolved.len() > 1 {
|
||||
notes.push("two loader inputs: primary chosen by nearest pHash, the other recorded as a reference — check".to_string());
|
||||
}
|
||||
if flat.len() > resolved.len() {
|
||||
notes.push("a loader name matched several files — check".into());
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
notes.push(format!(
|
||||
"other loader input(s) not in the library: {}",
|
||||
missing
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
if dist.is_none() {
|
||||
notes.push("no pHash to compare".into());
|
||||
}
|
||||
let mut references: Vec<String> = Vec::new();
|
||||
for (_, hits) in &resolved {
|
||||
for h in hits {
|
||||
if h.content_hash != primary.content_hash && !references.contains(&h.content_hash) {
|
||||
references.push(h.content_hash.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Proposal {
|
||||
status: Status::Review,
|
||||
method: "comfy-loader",
|
||||
library_id: row.library_id,
|
||||
version_path: row.rel_path.clone(),
|
||||
version_hash: row.content_hash.clone(),
|
||||
original_path: primary.rel_path.clone(),
|
||||
original_hash: primary.content_hash.clone(),
|
||||
kind: "edit".into(),
|
||||
distance: dist,
|
||||
references,
|
||||
notes: notes.join("; "),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_prefix(row: &FileRow, stem: &str, idx: &Index) -> Proposal {
|
||||
let dir = dir_of(&row.rel_path);
|
||||
let matches: Vec<&FileRow> = idx
|
||||
.by_dir
|
||||
.get(&(row.library_id, dir))
|
||||
.map(|v| {
|
||||
v.iter()
|
||||
.copied()
|
||||
.filter(|r| {
|
||||
r.content_hash != row.content_hash
|
||||
&& name_of(&r.rel_path)
|
||||
.rsplit_once('.')
|
||||
.is_some_and(|(s, _)| s == stem)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
match matches.as_slice() {
|
||||
[] => unresolved(
|
||||
row,
|
||||
"name-prefix",
|
||||
&format!("no file named {stem}.* beside it"),
|
||||
),
|
||||
[only] => Proposal {
|
||||
status: Status::Review,
|
||||
method: "name-prefix",
|
||||
library_id: row.library_id,
|
||||
version_path: row.rel_path.clone(),
|
||||
version_hash: row.content_hash.clone(),
|
||||
original_path: only.rel_path.clone(),
|
||||
original_hash: only.content_hash.clone(),
|
||||
kind: "upscale".into(),
|
||||
distance: distance(row, Some(only)),
|
||||
references: Vec::new(),
|
||||
notes: String::new(),
|
||||
},
|
||||
several => {
|
||||
let (best, d) = nearest(row, several).unwrap_or((several[0], u32::MAX));
|
||||
Proposal {
|
||||
status: Status::Review,
|
||||
method: "name-prefix",
|
||||
library_id: row.library_id,
|
||||
version_path: row.rel_path.clone(),
|
||||
version_hash: row.content_hash.clone(),
|
||||
original_path: best.rel_path.clone(),
|
||||
original_hash: best.content_hash.clone(),
|
||||
kind: "upscale".into(),
|
||||
distance: (d != u32::MAX).then_some(d),
|
||||
references: Vec::new(),
|
||||
notes: format!("{} files share that name — check", several.len()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_phash(row: &FileRow, rows: &[FileRow]) -> Proposal {
|
||||
let dir = dir_of(&row.rel_path);
|
||||
let parent = parent_of_dir(dir);
|
||||
let candidates: Vec<&FileRow> = rows
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.library_id == row.library_id
|
||||
&& r.content_hash != row.content_hash
|
||||
&& !is_aiedits_dir(dir_of(&r.rel_path))
|
||||
&& !looks_like_a_version(name_of(&r.rel_path))
|
||||
&& (dir_of(&r.rel_path) == parent || dir_of(&r.rel_path) == dir)
|
||||
})
|
||||
.collect();
|
||||
match nearest(row, &candidates) {
|
||||
Some((o, d)) if d <= MAX_PHASH_DISTANCE => Proposal {
|
||||
status: Status::Review,
|
||||
method: "phash",
|
||||
library_id: row.library_id,
|
||||
version_path: row.rel_path.clone(),
|
||||
version_hash: row.content_hash.clone(),
|
||||
original_path: o.rel_path.clone(),
|
||||
original_hash: o.content_hash.clone(),
|
||||
kind: "edit".into(),
|
||||
distance: Some(d),
|
||||
references: Vec::new(),
|
||||
notes: "matched by nearest pHash only".into(),
|
||||
},
|
||||
Some((o, d)) => unresolved(
|
||||
row,
|
||||
"phash",
|
||||
&format!("nearest pHash is {} at distance {d}, too far", o.rel_path),
|
||||
),
|
||||
None => unresolved(row, "phash", "no pHash candidates nearby"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`apply`] did.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct ApplyReport {
|
||||
pub linked: usize,
|
||||
pub already_linked: usize,
|
||||
pub refused: usize,
|
||||
/// Reviewed rows whose version row no longer matches the file in the TSV.
|
||||
pub stale: usize,
|
||||
}
|
||||
|
||||
/// Write the links. `exact` proposals apply as they are; `reviewed` come
|
||||
/// from a TSV a human kept, and are checked against `rows` first so a stale
|
||||
/// file can't link a path that now holds different bytes.
|
||||
pub fn apply(
|
||||
dao: &mut dyn VersionDao,
|
||||
exact: &[Proposal],
|
||||
reviewed: &[ReviewedLink],
|
||||
rows: &[FileRow],
|
||||
) -> ApplyReport {
|
||||
let mut report = ApplyReport::default();
|
||||
let current: HashMap<(i32, &str), &str> = rows
|
||||
.iter()
|
||||
.map(|r| ((r.library_id, r.rel_path.as_str()), r.content_hash.as_str()))
|
||||
.collect();
|
||||
|
||||
let mut links: Vec<NewVersionLink> = Vec::new();
|
||||
for p in exact.iter().filter(|p| p.status == Status::Exact) {
|
||||
links.push(NewVersionLink {
|
||||
version_hash: p.version_hash.clone(),
|
||||
original_hash: p.original_hash.clone(),
|
||||
kind: p.kind.clone(),
|
||||
source: if p.method == "xmp" {
|
||||
SOURCE_XMP
|
||||
} else {
|
||||
SOURCE_NAME
|
||||
}
|
||||
.to_string(),
|
||||
library_id: p.library_id,
|
||||
rel_path: p.version_path.clone(),
|
||||
references: p.references.clone(),
|
||||
});
|
||||
}
|
||||
for r in reviewed {
|
||||
if current.get(&(r.library_id, r.version_path.as_str())) != Some(&r.version_hash.as_str()) {
|
||||
report.stale += 1;
|
||||
continue;
|
||||
}
|
||||
links.push(NewVersionLink {
|
||||
version_hash: r.version_hash.clone(),
|
||||
original_hash: r.original_hash.clone(),
|
||||
kind: if is_known_kind(&r.kind) {
|
||||
r.kind.clone()
|
||||
} else {
|
||||
"edit".into()
|
||||
},
|
||||
source: SOURCE_BACKFILL.to_string(),
|
||||
library_id: r.library_id,
|
||||
rel_path: r.version_path.clone(),
|
||||
references: r.references.clone(),
|
||||
});
|
||||
}
|
||||
for link in links {
|
||||
match dao.link_version(&link) {
|
||||
Ok(LinkOutcome::Linked) => report.linked += 1,
|
||||
Ok(LinkOutcome::AlreadyLinked) => report.already_linked += 1,
|
||||
Ok(LinkOutcome::Rejected(_)) | Err(_) => report.refused += 1,
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::xmp::test_png::{producer_xmp, write_png};
|
||||
use super::*;
|
||||
use crate::database::SqliteVersionDao;
|
||||
use crate::database::test::in_memory_db_connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn row(rel: &str, hash: &str, phash: Option<i64>) -> FileRow {
|
||||
FileRow {
|
||||
library_id: 1,
|
||||
rel_path: rel.into(),
|
||||
content_hash: hash.into(),
|
||||
phash,
|
||||
}
|
||||
}
|
||||
|
||||
fn lib(root: &Path) -> Library {
|
||||
Library {
|
||||
id: 1,
|
||||
name: "main".into(),
|
||||
root_path: root.to_string_lossy().into(),
|
||||
enabled: true,
|
||||
excluded_dirs: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt(loaders: &[&str]) -> String {
|
||||
let mut nodes = serde_json::Map::new();
|
||||
nodes.insert(
|
||||
"1".into(),
|
||||
serde_json::json!({"class_type": "UNETLoader", "inputs": {"unet_name": "m"}}),
|
||||
);
|
||||
for (i, l) in loaders.iter().enumerate() {
|
||||
nodes.insert(
|
||||
(i + 2).to_string(),
|
||||
serde_json::json!({"class_type": "LoadImage", "inputs": {"image": l}}),
|
||||
);
|
||||
}
|
||||
serde_json::Value::Object(nodes).to_string()
|
||||
}
|
||||
|
||||
fn write_comfy_png(root: &Path, rel: &str, loaders: &[&str], salt: u8) {
|
||||
super::super::xmp::test_png::write_png(&root.join(rel), None, Some(&prompt(loaders)), salt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_names_reads_only_loadimage_inputs_in_node_order() {
|
||||
let p = prompt(&["a.jpg", "sub/b [input]"]);
|
||||
assert_eq!(loader_names(&p), vec!["a.jpg", "b"]);
|
||||
assert!(loader_names("not json").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_ingested_xmp_files_are_proposed_as_exact_without_naming_help() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let orig_hash = "d5".repeat(32);
|
||||
write_png(
|
||||
&tmp.path().join("2009/aiedits/019_19.JPG.restored.png"),
|
||||
Some(&producer_xmp(&orig_hash, "restored", &[])),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
// An XMP file whose name gives no hint (a renamed copy).
|
||||
write_png(
|
||||
&tmp.path().join("misc/renamed.png"),
|
||||
Some(&producer_xmp(
|
||||
&orig_hash,
|
||||
"upscale",
|
||||
&["ee".repeat(32).as_str()],
|
||||
)),
|
||||
None,
|
||||
2,
|
||||
);
|
||||
let rows = vec![
|
||||
row("2009/019_19.JPG", &orig_hash, None),
|
||||
row("2009/aiedits/019_19.JPG.restored.png", "v1", None),
|
||||
row("misc/renamed.png", "v2", None),
|
||||
];
|
||||
let plan = plan(&[lib(tmp.path())], &rows, &HashSet::new());
|
||||
let by_path: HashMap<&str, &Proposal> =
|
||||
plan.iter().map(|p| (p.version_path.as_str(), p)).collect();
|
||||
let a = by_path["2009/aiedits/019_19.JPG.restored.png"];
|
||||
assert_eq!((a.status, a.method), (Status::Exact, "xmp"));
|
||||
assert_eq!(a.original_path, "2009/019_19.JPG");
|
||||
let b = by_path["misc/renamed.png"];
|
||||
assert_eq!(
|
||||
(b.status, b.method, b.kind.as_str()),
|
||||
(Status::Exact, "xmp", "upscale")
|
||||
);
|
||||
assert_eq!(b.references.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comfy_outputs_are_resolved_by_loader_and_flagged_when_two_inputs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_comfy_png(tmp.path(), "Amanda/aiedits/out_00001_.png", &["a.jpg"], 1);
|
||||
write_comfy_png(
|
||||
tmp.path(),
|
||||
"Amanda/aiedits/out_00002_.png",
|
||||
&["a.jpg", "b.jpg"],
|
||||
2,
|
||||
);
|
||||
write_comfy_png(
|
||||
tmp.path(),
|
||||
"Amanda/aiedits/out_00003_.png",
|
||||
&["typo.jpg"],
|
||||
3,
|
||||
);
|
||||
let rows = vec![
|
||||
row("Amanda/a.jpg", "ha", Some(0b1111)),
|
||||
row("Amanda/b.jpg", "hb", Some(-1)),
|
||||
row("Amanda/aiedits/out_00001_.png", "v1", Some(0b1110)),
|
||||
row("Amanda/aiedits/out_00002_.png", "v2", Some(0b1011)),
|
||||
row("Amanda/aiedits/out_00003_.png", "v3", Some(0)),
|
||||
];
|
||||
let plan = plan(&[lib(tmp.path())], &rows, &HashSet::new());
|
||||
let get = |p: &str| plan.iter().find(|x| x.version_path == p).unwrap();
|
||||
|
||||
let one = get("Amanda/aiedits/out_00001_.png");
|
||||
assert_eq!((one.status, one.method), (Status::Review, "comfy-loader"));
|
||||
assert_eq!(one.original_path, "Amanda/a.jpg");
|
||||
assert_eq!(one.distance, Some(1));
|
||||
assert!(one.references.is_empty());
|
||||
|
||||
let two = get("Amanda/aiedits/out_00002_.png");
|
||||
assert_eq!(
|
||||
two.original_path, "Amanda/a.jpg",
|
||||
"primary by nearest pHash"
|
||||
);
|
||||
assert_eq!(two.references, vec!["hb"]);
|
||||
assert!(two.notes.contains("two loader inputs"));
|
||||
|
||||
let lost = get("Amanda/aiedits/out_00003_.png");
|
||||
assert_eq!(lost.status, Status::Unresolved);
|
||||
assert!(lost.original_path.is_empty(), "listed, not guessed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscayl_names_and_phash_fallback() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_png(&tmp.path().join("p/att_1-4x.png"), None, None, 1);
|
||||
write_png(
|
||||
&tmp.path().join("p/pic_upscayl_3x_ultramix_balanced.png"),
|
||||
None,
|
||||
None,
|
||||
2,
|
||||
);
|
||||
write_png(&tmp.path().join("p/aiedits/mystery.png"), None, None, 3);
|
||||
let rows = vec![
|
||||
row("p/att_1.jpg", "h1", Some(5)),
|
||||
row("p/pic.jpeg", "h2", Some(5)),
|
||||
row("p/other.jpg", "h3", Some(0b0111_1111_1111)),
|
||||
row("p/att_1-4x.png", "u1", Some(5)),
|
||||
row("p/pic_upscayl_3x_ultramix_balanced.png", "u2", Some(5)),
|
||||
row("p/aiedits/mystery.png", "m1", Some(7)),
|
||||
];
|
||||
let plan = plan(&[lib(tmp.path())], &rows, &HashSet::new());
|
||||
let get = |p: &str| plan.iter().find(|x| x.version_path == p).unwrap();
|
||||
assert_eq!(get("p/att_1-4x.png").original_path, "p/att_1.jpg");
|
||||
assert_eq!(get("p/att_1-4x.png").kind, "upscale");
|
||||
assert_eq!(
|
||||
get("p/pic_upscayl_3x_ultramix_balanced.png").original_path,
|
||||
"p/pic.jpeg"
|
||||
);
|
||||
let m = get("p/aiedits/mystery.png");
|
||||
assert_eq!(m.method, "phash");
|
||||
assert_eq!(m.status, Status::Review);
|
||||
assert_eq!(
|
||||
m.original_path, "p/att_1.jpg",
|
||||
"distance 2 beats the others"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_rows_are_not_proposed_again() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_png(&tmp.path().join("a.jpg.restored.png"), None, None, 1);
|
||||
let rows = vec![
|
||||
row("a.jpg", "o", None),
|
||||
row("a.jpg.restored.png", "v", None),
|
||||
];
|
||||
let linked = HashSet::from(["v".to_string()]);
|
||||
assert!(plan(&[lib(tmp.path())], &rows, &linked).is_empty());
|
||||
let p = plan(&[lib(tmp.path())], &rows, &HashSet::new());
|
||||
assert_eq!((p[0].status, p[0].method), (Status::Exact, "name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsv_round_trip_and_reviewed_application() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_comfy_png(tmp.path(), "d/aiedits/o_00001_.png", &["a.jpg", "b.jpg"], 1);
|
||||
let rows = vec![
|
||||
row("d/a.jpg", "ha", Some(1)),
|
||||
row("d/b.jpg", "hb", Some(-1)),
|
||||
row("d/aiedits/o_00001_.png", "v", Some(1)),
|
||||
row("d/x.jpg", "hx", None),
|
||||
row("d/x.jpg.edit.png", "vx", None),
|
||||
];
|
||||
write_png(&tmp.path().join("d/x.jpg.edit.png"), None, None, 9);
|
||||
let planned = plan(&[lib(tmp.path())], &rows, &HashSet::new());
|
||||
let tsv: String = std::iter::once(TSV_HEADER.to_string())
|
||||
.chain(planned.iter().map(Proposal::to_tsv))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let (reviewed, skipped) = parse_reviewed(&tsv);
|
||||
assert_eq!(
|
||||
reviewed.len(),
|
||||
1,
|
||||
"only the review row; the exact one isn't re-applied from the file"
|
||||
);
|
||||
assert_eq!(skipped, 1);
|
||||
assert_eq!(reviewed[0].references, vec!["hb"]);
|
||||
|
||||
let conn = Arc::new(Mutex::new(in_memory_db_connection()));
|
||||
let mut dao = SqliteVersionDao::from_shared(conn);
|
||||
// Without --reviewed: exact only.
|
||||
let report = apply(&mut dao, &planned, &[], &rows);
|
||||
assert_eq!(report.linked, 1);
|
||||
assert!(dao.get_link("vx").unwrap().is_some());
|
||||
assert!(
|
||||
dao.get_link("v").unwrap().is_none(),
|
||||
"heuristic rows never auto-apply"
|
||||
);
|
||||
// With the reviewed file.
|
||||
let report = apply(&mut dao, &planned, &reviewed, &rows);
|
||||
assert_eq!(report.linked, 1);
|
||||
assert_eq!(report.already_linked, 1);
|
||||
let l = dao.get_link("v").unwrap().unwrap();
|
||||
assert_eq!(
|
||||
(l.original_hash.as_str(), l.source.as_str()),
|
||||
("ha", "backfill")
|
||||
);
|
||||
assert_eq!(dao.list_references("v").unwrap(), vec!["hb"]);
|
||||
|
||||
// A stale TSV (the path now holds other bytes) is refused.
|
||||
let mut stale_rows = rows.clone();
|
||||
stale_rows[2].content_hash = "changed".into();
|
||||
let report = apply(&mut dao, &[], &reviewed, &stale_rows);
|
||||
assert_eq!(report.stale, 1);
|
||||
}
|
||||
#[test]
|
||||
fn apply_drops_derived_data_of_an_already_ingested_version() {
|
||||
let conn = Arc::new(Mutex::new(in_memory_db_connection()));
|
||||
let exec = |sql: &str| {
|
||||
diesel::RunQueryDsl::execute(diesel::sql_query(sql), &mut *conn.lock().unwrap())
|
||||
.unwrap_or_else(|e| panic!("{sql}: {e}"));
|
||||
};
|
||||
exec(
|
||||
"INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash, clip_embedding, clip_model_version) VALUES (1, 'v.png', 0, 0, 'v', x'00', 'm')",
|
||||
);
|
||||
exec(
|
||||
"INSERT INTO face_detections (library_id, content_hash, rel_path, bbox_x, embedding, source, status, model_version, created_at) VALUES (1, 'v', 'v.png', 0.1, x'00', 'auto', 'detected', 'm', 0)",
|
||||
);
|
||||
exec(
|
||||
"INSERT INTO clip_failures (library_id, rel_path, content_hash, error, failed_at) VALUES (1, 'v.png', 'v', 'boom', 0)",
|
||||
);
|
||||
let mut dao = SqliteVersionDao::from_shared(conn.clone());
|
||||
let rows = vec![row("v.png", "v", None)];
|
||||
let reviewed = vec![ReviewedLink {
|
||||
method: "phash".into(),
|
||||
library_id: 1,
|
||||
version_path: "v.png".into(),
|
||||
kind: "edit".into(),
|
||||
version_hash: "v".into(),
|
||||
original_hash: "o".into(),
|
||||
references: vec![],
|
||||
}];
|
||||
assert_eq!(apply(&mut dao, &[], &reviewed, &rows).linked, 1);
|
||||
|
||||
#[derive(diesel::QueryableByName)]
|
||||
struct N {
|
||||
#[diesel(sql_type = diesel::sql_types::BigInt)]
|
||||
n: i64,
|
||||
}
|
||||
for sql in [
|
||||
"SELECT COUNT(*) AS n FROM face_detections WHERE content_hash = 'v'",
|
||||
"SELECT COUNT(*) AS n FROM clip_failures WHERE content_hash = 'v'",
|
||||
"SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = 'v' AND (clip_embedding IS NOT NULL OR clip_model_version IS NOT NULL)",
|
||||
] {
|
||||
let n = diesel::RunQueryDsl::get_result::<N>(
|
||||
diesel::sql_query(sql),
|
||||
&mut *conn.lock().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.n;
|
||||
assert_eq!(n, 0, "{sql}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! `GET /image/versions` and `PUT /image/versions/display`.
|
||||
//!
|
||||
//! Both take the path of the original *or* of any version, and answer about
|
||||
//! the whole set. The display choice is global (not per user).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use actix_web::{HttpResponse, Responder, get, put, web};
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::data::Claims;
|
||||
use crate::database::ExifDao;
|
||||
use crate::database::version_dao::VersionDao;
|
||||
use crate::libraries;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::normalize_path;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VersionsQuery {
|
||||
pub path: String,
|
||||
pub library: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetDisplayBody {
|
||||
pub path: String,
|
||||
/// Content hash of the version to display; `null` / absent = the original.
|
||||
#[serde(default)]
|
||||
pub display_hash: Option<String>,
|
||||
pub library: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct FileRef {
|
||||
pub path: String,
|
||||
pub library_id: i32,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct ReferenceEntry {
|
||||
pub hash: String,
|
||||
/// Where the reference photo lives, if it has been ingested.
|
||||
pub path: Option<String>,
|
||||
pub library_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct VersionEntry {
|
||||
pub path: String,
|
||||
pub library_id: i32,
|
||||
pub hash: String,
|
||||
pub kind: String,
|
||||
pub source: String,
|
||||
pub created_at: i64,
|
||||
pub references: Vec<ReferenceEntry>,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct VersionsResponse {
|
||||
/// `null` when the original's file is gone (its versions then show up as
|
||||
/// standalone photos).
|
||||
pub original: Option<FileRef>,
|
||||
pub versions: Vec<VersionEntry>,
|
||||
/// The globally displayed version; `null` = the original.
|
||||
pub display_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// The photo a request path belongs to.
|
||||
struct Resolved {
|
||||
original_hash: String,
|
||||
/// Library of the row the path named; preferred when picking files.
|
||||
library_id: i32,
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
app_state: &AppState,
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
path: &str,
|
||||
library: Option<&str>,
|
||||
) -> Result<Resolved, HttpResponse> {
|
||||
let library_id = match libraries::resolve_library_param_state(app_state, library) {
|
||||
Ok(lib) => lib.map(|l| l.id),
|
||||
Err(msg) => return Err(HttpResponse::BadRequest().body(msg)),
|
||||
};
|
||||
let path = normalize_path(path);
|
||||
let mut rows = exif_dao
|
||||
.lock()
|
||||
.expect("Unable to lock ExifDao")
|
||||
.get_exif_batch(library_id, std::slice::from_ref(&path))
|
||||
.map_err(|_| HttpResponse::InternalServerError().finish())?;
|
||||
// Without `library`, a path can name rows in several libraries. Take the
|
||||
// earliest hashed one; it then resolves to its original below, so a
|
||||
// version in one library and its original in another agree.
|
||||
rows.sort_by_key(|r| r.id);
|
||||
let Some((row, hash)) = rows
|
||||
.iter()
|
||||
.find_map(|r| r.content_hash.clone().map(|h| (r, h)))
|
||||
else {
|
||||
return Err(HttpResponse::NotFound().body("no indexed file at that path"));
|
||||
};
|
||||
let link = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.get_link(&hash)
|
||||
.map_err(|_| HttpResponse::InternalServerError().finish())?;
|
||||
Ok(Resolved {
|
||||
original_hash: link.map(|l| l.original_hash).unwrap_or(hash),
|
||||
library_id: row.library_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything about the set around `original_hash`. Reads fall back to the
|
||||
/// original when the stored display choice has no live row.
|
||||
pub fn build_response(
|
||||
dao: &mut dyn VersionDao,
|
||||
original_hash: &str,
|
||||
prefer_library: i32,
|
||||
) -> Result<VersionsResponse, crate::database::DbError> {
|
||||
let original = dao
|
||||
.files_for_hashes(&[original_hash.to_string()], Some(prefer_library))?
|
||||
.remove(original_hash)
|
||||
.map(|(library_id, path)| FileRef {
|
||||
path,
|
||||
library_id,
|
||||
hash: original_hash.to_string(),
|
||||
});
|
||||
|
||||
let files = dao.list_versions(original_hash)?;
|
||||
let mut versions = Vec::with_capacity(files.len());
|
||||
for f in files {
|
||||
let reference_hashes = dao.list_references(&f.version_hash)?;
|
||||
let located = dao.files_for_hashes(&reference_hashes, Some(f.library_id))?;
|
||||
let references = reference_hashes
|
||||
.into_iter()
|
||||
.map(|hash| {
|
||||
let found = located.get(&hash);
|
||||
ReferenceEntry {
|
||||
path: found.map(|(_, p)| p.clone()),
|
||||
library_id: found.map(|(l, _)| *l),
|
||||
hash,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
versions.push(VersionEntry {
|
||||
path: f.rel_path,
|
||||
library_id: f.library_id,
|
||||
hash: f.version_hash,
|
||||
kind: f.kind,
|
||||
source: f.source,
|
||||
created_at: f.created_at,
|
||||
references,
|
||||
width: f.width,
|
||||
height: f.height,
|
||||
});
|
||||
}
|
||||
|
||||
// Clients fetch the display file through the original's library, so a
|
||||
// choice with no row there reads as the original (as `/photos` does).
|
||||
let display_hash = dao.get_display(original_hash)?.filter(|d| {
|
||||
versions.iter().any(|v| {
|
||||
&v.hash == d
|
||||
&& original
|
||||
.as_ref()
|
||||
.is_none_or(|o| o.library_id == v.library_id)
|
||||
})
|
||||
});
|
||||
Ok(VersionsResponse {
|
||||
original,
|
||||
versions,
|
||||
display_hash,
|
||||
})
|
||||
}
|
||||
|
||||
#[get("/image/versions")]
|
||||
#[tracing::instrument(name = "get_versions", skip_all)]
|
||||
pub async fn get_versions(
|
||||
_: Claims,
|
||||
q: web::Query<VersionsQuery>,
|
||||
app_state: web::Data<AppState>,
|
||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> impl Responder {
|
||||
let resolved = match resolve(&app_state, &exif_dao, &q.path, q.library.as_deref()) {
|
||||
Ok(r) => r,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let mut dao = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao");
|
||||
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
|
||||
Ok(body) => HttpResponse::Ok().json(body),
|
||||
Err(e) => {
|
||||
warn!("get_versions failed: {:?}", e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/image/versions/display")]
|
||||
#[tracing::instrument(name = "put_version_display", skip_all)]
|
||||
pub async fn put_display(
|
||||
_: Claims,
|
||||
// The client also sends `library` as a query parameter, like its other
|
||||
// path-keyed calls; the body field wins when both are given.
|
||||
query: web::Query<LibraryQuery>,
|
||||
body: web::Json<SetDisplayBody>,
|
||||
app_state: web::Data<AppState>,
|
||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> impl Responder {
|
||||
let library = body.library.as_deref().or(query.library.as_deref());
|
||||
let resolved = match resolve(&app_state, &exif_dao, &body.path, library) {
|
||||
Ok(r) => r,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let mut dao = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao");
|
||||
|
||||
// Choosing the original is the same as clearing the choice.
|
||||
let wanted = body
|
||||
.display_hash
|
||||
.as_deref()
|
||||
.filter(|h| *h != resolved.original_hash);
|
||||
if let Some(hash) = wanted {
|
||||
let current =
|
||||
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("put_display lookup failed: {:?}", e);
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
if !current.versions.iter().any(|v| v.hash == hash) {
|
||||
return HttpResponse::BadRequest().body("display_hash is not a version of this photo");
|
||||
}
|
||||
// The display file is fetched through the original's library.
|
||||
let original_lib = current.original.as_ref().map(|o| o.library_id);
|
||||
if !current
|
||||
.versions
|
||||
.iter()
|
||||
.any(|v| v.hash == hash && original_lib.is_none_or(|l| l == v.library_id))
|
||||
{
|
||||
return HttpResponse::BadRequest()
|
||||
.body("display_hash has no file in the original's library");
|
||||
}
|
||||
}
|
||||
if let Err(e) = dao.set_display(&resolved.original_hash, wanted) {
|
||||
warn!("put_display failed: {:?}", e);
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
|
||||
Ok(body) => HttpResponse::Ok().json(body),
|
||||
Err(e) => {
|
||||
warn!("put_display response failed: {:?}", e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LibraryQuery {
|
||||
pub library: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
//! End-to-end checks for photo versions (#191): linking at ingest, hiding in
|
||||
//! listings, the HTTP surface, tag/favorite binding and the lifecycle rules.
|
||||
//! Everything runs against one shared in-memory SQLite and a temp library
|
||||
//! root; nothing reads a real library.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use actix_web::test as awtest;
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{App, web};
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_query;
|
||||
use diesel::sql_types::{BigInt, Text};
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
|
||||
use super::xmp::test_png::{producer_xmp, write_png};
|
||||
use super::*;
|
||||
use crate::data::Claims;
|
||||
use crate::database::test::in_memory_db_connection;
|
||||
use crate::database::{ExifDao, FavoriteDao, SqliteExifDao, SqliteFavoriteDao, SqliteVersionDao};
|
||||
use crate::files::{RealFileSystem, list_photos};
|
||||
use crate::library_maintenance;
|
||||
use crate::state::AppState;
|
||||
use crate::tags::{SqliteTagDao, TagDao};
|
||||
|
||||
const LIB: i32 = 1;
|
||||
|
||||
struct Fx {
|
||||
state: Data<AppState>,
|
||||
conn: Arc<Mutex<SqliteConnection>>,
|
||||
exif: Data<Mutex<Box<dyn ExifDao>>>,
|
||||
library: Library,
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
fn fx() -> Fx {
|
||||
let mut state = AppState::test_state();
|
||||
let conn = Arc::new(Mutex::new(in_memory_db_connection()));
|
||||
state.version_dao = Arc::new(Mutex::new(Box::new(SqliteVersionDao::from_shared(
|
||||
conn.clone(),
|
||||
))));
|
||||
let library = state.primary_library().clone();
|
||||
let root = PathBuf::from(&library.root_path);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
// The migration seeds library 1 with a placeholder root.
|
||||
sql_query("UPDATE libraries SET root_path = ? WHERE id = 1")
|
||||
.bind::<Text, _>(&library.root_path)
|
||||
.execute(&mut *conn.lock().unwrap())
|
||||
.unwrap();
|
||||
Fx {
|
||||
exif: Data::new(Mutex::new(
|
||||
Box::new(SqliteExifDao::from_shared(conn.clone())) as Box<dyn ExifDao>,
|
||||
)),
|
||||
state: Data::new(state),
|
||||
conn,
|
||||
library,
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
impl Fx {
|
||||
fn exec(&self, sql: &str) {
|
||||
sql_query(sql)
|
||||
.execute(&mut *self.conn.lock().unwrap())
|
||||
.unwrap_or_else(|e| panic!("{sql}: {e}"));
|
||||
}
|
||||
|
||||
/// Write a file under the library root and register it in `image_exif`
|
||||
/// with its real content hash. Returns the hash.
|
||||
fn put(&self, rel: &str, bytes: &[u8]) -> String {
|
||||
let abs = self.root.join(rel);
|
||||
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
|
||||
std::fs::write(&abs, bytes).unwrap();
|
||||
self.register(rel)
|
||||
}
|
||||
|
||||
fn put_png(&self, rel: &str, xmp: Option<&str>, salt: u8) -> String {
|
||||
write_png(&self.root.join(rel), xmp, None, salt);
|
||||
self.register(rel)
|
||||
}
|
||||
|
||||
fn register(&self, rel: &str) -> String {
|
||||
let hash = crate::content_hash::compute(&self.root.join(rel))
|
||||
.unwrap()
|
||||
.content_hash;
|
||||
sql_query(
|
||||
"INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash) \
|
||||
VALUES (1, ?, 0, 0, ?)",
|
||||
)
|
||||
.bind::<Text, _>(rel)
|
||||
.bind::<Text, _>(&hash)
|
||||
.execute(&mut *self.conn.lock().unwrap())
|
||||
.unwrap();
|
||||
hash
|
||||
}
|
||||
|
||||
fn forget(&self, rel: &str) {
|
||||
sql_query("DELETE FROM image_exif WHERE rel_path = ?")
|
||||
.bind::<Text, _>(rel)
|
||||
.execute(&mut *self.conn.lock().unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn exif_arc(&self) -> Arc<Mutex<Box<dyn ExifDao>>> {
|
||||
Arc::new(Mutex::new(
|
||||
Box::new(SqliteExifDao::from_shared(self.conn.clone())) as Box<dyn ExifDao>,
|
||||
))
|
||||
}
|
||||
|
||||
fn link(&self, version_hash: &str, original_hash: &str, rel: &str) {
|
||||
let outcome = self
|
||||
.state
|
||||
.version_dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.link_version(&NewVersionLink {
|
||||
version_hash: version_hash.into(),
|
||||
original_hash: original_hash.into(),
|
||||
kind: "restored".into(),
|
||||
source: "xmp".into(),
|
||||
library_id: LIB,
|
||||
rel_path: rel.into(),
|
||||
references: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(outcome, LinkOutcome::Linked);
|
||||
}
|
||||
|
||||
fn token() -> String {
|
||||
jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::default(),
|
||||
&Claims::valid_user("1".to_string()),
|
||||
&jsonwebtoken::EncodingKey::from_secret(b"test_key"),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn count(&self, sql: &str) -> i64 {
|
||||
#[derive(QueryableByName)]
|
||||
struct N {
|
||||
#[diesel(sql_type = BigInt)]
|
||||
n: i64,
|
||||
}
|
||||
sql_query(sql)
|
||||
.get_result::<N>(&mut *self.conn.lock().unwrap())
|
||||
.unwrap()
|
||||
.n
|
||||
}
|
||||
}
|
||||
|
||||
fn dao_listing(fx: &Fx, include_versions: bool) -> Vec<String> {
|
||||
let mut v: Vec<String> = fx
|
||||
.exif
|
||||
.lock()
|
||||
.unwrap()
|
||||
.list_rel_paths_for_libraries(&[], None, false, include_versions)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(_, p)| p)
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
// ── Ingest ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn xmp_link_is_made_at_ingest_and_survives_a_rename() {
|
||||
let fx = fx();
|
||||
let original = fx.put("2004/scan_0042.jpg", b"original bytes");
|
||||
let xmp = producer_xmp(&original, "restored", &[]);
|
||||
// The producer saved it as `aiedits/<name>.restored.png`; here it was
|
||||
// renamed and moved somewhere the name convention can't explain.
|
||||
fx.put_png("somewhere/else/renamed copy.png", Some(&xmp), 1);
|
||||
|
||||
let files = vec![(
|
||||
fx.root.join("somewhere/else/renamed copy.png"),
|
||||
"somewhere/else/renamed copy.png".to_string(),
|
||||
)];
|
||||
let linked = link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao);
|
||||
assert_eq!(linked, 1);
|
||||
|
||||
let (vh, _) = {
|
||||
let row = fx
|
||||
.exif
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_exif("somewhere/else/renamed copy.png")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(row.content_hash.unwrap(), ())
|
||||
};
|
||||
let link = fx
|
||||
.state
|
||||
.version_dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_link(&vh)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(link.original_hash, original);
|
||||
assert_eq!(link.kind, "restored");
|
||||
assert_eq!(link.source, "xmp");
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn xmp_references_are_provenance_only() {
|
||||
let fx = fx();
|
||||
let original = fx.put("a/main.jpg", b"main");
|
||||
let other = fx.put("a/other-view.jpg", b"another view of the subject");
|
||||
let xmp = producer_xmp(&original, "edit", &[&other]);
|
||||
let vh = fx.put_png("a/aiedits/main.jpg.edit.png", Some(&xmp), 2);
|
||||
let files = vec![(
|
||||
fx.root.join("a/aiedits/main.jpg.edit.png"),
|
||||
"a/aiedits/main.jpg.edit.png".to_string(),
|
||||
)];
|
||||
assert_eq!(
|
||||
link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao),
|
||||
1
|
||||
);
|
||||
let mut dao = fx.state.version_dao.lock().unwrap();
|
||||
assert_eq!(dao.get_link(&vh).unwrap().unwrap().original_hash, original);
|
||||
assert_eq!(dao.list_references(&vh).unwrap(), vec![other.clone()]);
|
||||
// The reference photo is not linked to anything and stays listed.
|
||||
assert!(dao.get_link(&other).unwrap().is_none());
|
||||
drop(dao);
|
||||
assert!(dao_listing(&fx, false).contains(&"a/other-view.jpg".to_string()));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn name_link_pairs_raw_and_jpeg_with_the_right_original() {
|
||||
let fx = fx();
|
||||
let raw = fx.put("trip/IMG_1.NEF", b"raw bytes");
|
||||
let jpg = fx.put("trip/IMG_1.JPG", b"jpeg bytes");
|
||||
let v_raw = fx.put_png("trip/aiedits/IMG_1.NEF.restored.png", None, 3);
|
||||
let v_jpg = fx.put_png("trip/aiedits/IMG_1.JPG.restored.2.png", None, 4);
|
||||
|
||||
let n = sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao);
|
||||
assert_eq!(n, 2);
|
||||
let mut dao = fx.state.version_dao.lock().unwrap();
|
||||
let l_raw = dao.get_link(&v_raw).unwrap().unwrap();
|
||||
let l_jpg = dao.get_link(&v_jpg).unwrap().unwrap();
|
||||
assert_eq!(l_raw.original_hash, raw);
|
||||
assert_eq!(l_jpg.original_hash, jpg);
|
||||
assert_eq!(l_raw.source, "name");
|
||||
assert_eq!(l_raw.kind, "restored");
|
||||
drop(dao);
|
||||
// Idempotent.
|
||||
assert_eq!(
|
||||
sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn name_link_waits_for_the_original_to_be_hashed() {
|
||||
let fx = fx();
|
||||
let vh = fx.put_png("a/photo.jpg.upscale.png", None, 5);
|
||||
// Original row exists but its hash hasn't landed yet.
|
||||
fx.exec(
|
||||
"INSERT INTO image_exif (library_id, rel_path, created_time, last_modified) \
|
||||
VALUES (1, 'a/photo.jpg', 0, 0)",
|
||||
);
|
||||
assert_eq!(
|
||||
sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao),
|
||||
0,
|
||||
"no link by path alone while the original is hash-less"
|
||||
);
|
||||
let original_hash = "ab".repeat(32);
|
||||
fx.exec(&format!(
|
||||
"UPDATE image_exif SET content_hash = '{original_hash}' WHERE rel_path = 'a/photo.jpg'"
|
||||
));
|
||||
assert_eq!(
|
||||
sweep_name_links(&fx.library, &fx.exif_arc(), &fx.state.version_dao),
|
||||
1
|
||||
);
|
||||
let link = fx
|
||||
.state
|
||||
.version_dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_link(&vh)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(link.original_hash, original_hash);
|
||||
assert_eq!(link.kind, "upscale");
|
||||
}
|
||||
|
||||
// ── Listings ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build the test service: `/photos` plus the version endpoints, over the
|
||||
/// fixture's shared connection.
|
||||
macro_rules! photos_app {
|
||||
($fx:expr) => {{
|
||||
let fx: &Fx = &$fx;
|
||||
let tag_dao = Data::new(Mutex::new(SqliteTagDao::new(fx.conn.clone())));
|
||||
let fs = Data::new(RealFileSystem::new(fx.library.root_path.clone()));
|
||||
awtest::init_service(
|
||||
App::new()
|
||||
.service(
|
||||
web::resource("/photos")
|
||||
.route(web::get().to(list_photos::<SqliteTagDao, RealFileSystem>)),
|
||||
)
|
||||
.service(super::handlers::get_versions)
|
||||
.service(super::handlers::put_display)
|
||||
.app_data(fx.state.clone())
|
||||
.app_data(fx.exif.clone())
|
||||
.app_data(tag_dao)
|
||||
.app_data(fs),
|
||||
)
|
||||
.await
|
||||
}};
|
||||
}
|
||||
|
||||
/// GET `uri` as an authenticated user: `(status, json body)`.
|
||||
macro_rules! get_json {
|
||||
($app:expr, $uri:expr) => {{
|
||||
let req = awtest::TestRequest::get()
|
||||
.uri($uri)
|
||||
.insert_header(("Authorization", format!("Bearer {}", Fx::token())))
|
||||
.to_request();
|
||||
let resp = awtest::call_service(&$app, req).await;
|
||||
let status = resp.status().as_u16();
|
||||
let body = awtest::read_body(resp).await;
|
||||
(
|
||||
status,
|
||||
serde_json::from_slice::<serde_json::Value>(&body).unwrap_or_default(),
|
||||
)
|
||||
}};
|
||||
}
|
||||
|
||||
/// A photo with a restoration, one reference-less edit and an unrelated photo.
|
||||
fn seeded() -> (Fx, String, String, String) {
|
||||
let fx = fx();
|
||||
let original = fx.put("2004/scan.jpg", b"scan bytes");
|
||||
fx.put("2004/unrelated.jpg", b"something else");
|
||||
let v1 = fx.put_png("2004/aiedits/scan.jpg.restored.png", None, 7);
|
||||
let v2 = fx.put_png("2004/aiedits/scan.jpg.edit.png", None, 8);
|
||||
fx.link(&v1, &original, "2004/aiedits/scan.jpg.restored.png");
|
||||
fx.link(&v2, &original, "2004/aiedits/scan.jpg.edit.png");
|
||||
(fx, original, v1, v2)
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn photos_hides_versions_and_adds_parallel_arrays() {
|
||||
let (fx, _o, v1, _v2) = seeded();
|
||||
let app = photos_app!(fx);
|
||||
|
||||
// Recursive (DB-backed) listing.
|
||||
let (status, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc");
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(
|
||||
body["photos"],
|
||||
serde_json::json!(["2004/scan.jpg", "2004/unrelated.jpg"])
|
||||
);
|
||||
// No display choice yet: counts only; display paths are skipped.
|
||||
assert_eq!(body["photo_version_counts"], serde_json::json!([2, 0]));
|
||||
assert!(body.get("photo_display_paths").is_none());
|
||||
|
||||
// Non-recursive (filesystem walk) listing of the folder with the original.
|
||||
let (_, body) = get_json!(app, "/photos?path=2004&sort=nameasc");
|
||||
assert_eq!(
|
||||
body["photos"],
|
||||
serde_json::json!(["2004/scan.jpg", "2004/unrelated.jpg"])
|
||||
);
|
||||
// ...and of the aiedits folder itself: the versions are hidden there too.
|
||||
let (_, body) = get_json!(app, "/photos?path=2004/aiedits&sort=nameasc");
|
||||
assert_eq!(body["photos"], serde_json::json!([]));
|
||||
|
||||
// include_versions lists them as photos of their own.
|
||||
let (_, body) = get_json!(
|
||||
app,
|
||||
"/photos?path=&recursive=true&sort=nameasc&include_versions=true"
|
||||
);
|
||||
assert_eq!(body["photos"].as_array().unwrap().len(), 4);
|
||||
|
||||
// A global display choice shows up as photo_display_paths.
|
||||
let req = awtest::TestRequest::put()
|
||||
.uri("/image/versions/display")
|
||||
.insert_header(("Authorization", format!("Bearer {}", Fx::token())))
|
||||
.set_json(serde_json::json!({"path": "2004/scan.jpg", "display_hash": v1}))
|
||||
.to_request();
|
||||
assert_eq!(awtest::call_service(&app, req).await.status(), 200);
|
||||
let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc");
|
||||
assert_eq!(
|
||||
body["photo_display_paths"],
|
||||
serde_json::json!(["2004/aiedits/scan.jpg.restored.png", null])
|
||||
);
|
||||
assert_eq!(
|
||||
body["photos"][0], "2004/scan.jpg",
|
||||
"photos[i] stays the original"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn photos_without_versions_is_unchanged() {
|
||||
let fx = fx();
|
||||
fx.put("a.jpg", b"a");
|
||||
fx.put("b.jpg", b"b");
|
||||
let app = photos_app!(fx);
|
||||
let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc");
|
||||
let keys: Vec<&String> = body.as_object().unwrap().keys().collect();
|
||||
assert!(
|
||||
!keys
|
||||
.iter()
|
||||
.any(|k| k.starts_with("photo_display") || k.starts_with("photo_version"))
|
||||
);
|
||||
assert_eq!(body["photos"], serde_json::json!(["a.jpg", "b.jpg"]));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn deleting_the_original_surfaces_the_version() {
|
||||
let (fx, _o, _v1, _v2) = seeded();
|
||||
fx.forget("2004/scan.jpg");
|
||||
let app = photos_app!(fx);
|
||||
let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc");
|
||||
assert_eq!(
|
||||
body["photos"],
|
||||
serde_json::json!([
|
||||
"2004/aiedits/scan.jpg.edit.png",
|
||||
"2004/aiedits/scan.jpg.restored.png",
|
||||
"2004/unrelated.jpg"
|
||||
])
|
||||
);
|
||||
assert!(body.get("photo_version_counts").is_none());
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn library_scoped_listing_hides_only_when_the_original_is_in_view() {
|
||||
let (fx, original, v1, _v2) = seeded();
|
||||
fx.exec(
|
||||
"INSERT INTO libraries (id, name, root_path, created_at) VALUES (2, 'two', '/tmp/two', 0)",
|
||||
);
|
||||
// Move the original into library 2.
|
||||
fx.exec(&format!(
|
||||
"UPDATE image_exif SET library_id = 2 WHERE content_hash = '{original}'"
|
||||
));
|
||||
let lib1 = fx
|
||||
.exif
|
||||
.lock()
|
||||
.unwrap()
|
||||
.list_rel_paths_for_libraries(&[1], None, false, false)
|
||||
.unwrap();
|
||||
assert!(
|
||||
lib1.iter().any(|(_, p)| p.ends_with("restored.png")),
|
||||
"original is outside library 1: the version stays visible"
|
||||
);
|
||||
let all = dao_listing(&fx, false);
|
||||
assert!(!all.iter().any(|p| p.ends_with("restored.png")));
|
||||
let _ = v1;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn exif_gps_and_memories_queries_hide_versions() {
|
||||
let (fx, original, v1, _v2) = seeded();
|
||||
fx.exec(&format!(
|
||||
"UPDATE image_exif SET date_taken = 1500000000, date_utc = 1500000000, \
|
||||
gps_latitude = 10.5, gps_longitude = 20.5, camera_make = 'Canon', camera_model = 'EOS', \
|
||||
date_taken_source = 'exif' WHERE content_hash = '{original}'"
|
||||
));
|
||||
// Copy to the versions (what the watcher does each tick).
|
||||
assert_eq!(
|
||||
fx.state
|
||||
.version_dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_version_metadata()
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
fx.count(&format!(
|
||||
"SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{v1}' \
|
||||
AND date_utc = 1500000000 AND gps_latitude = 10.5 AND camera_make = 'Canon' \
|
||||
AND date_taken_source = 'version_of'"
|
||||
)),
|
||||
1,
|
||||
"a version takes the original's date, GPS and camera"
|
||||
);
|
||||
|
||||
let mut dao = fx.exif.lock().unwrap();
|
||||
let gps = dao.get_all_with_gps("", true).unwrap();
|
||||
assert_eq!(gps.len(), 1, "GPS summary shows the photo once");
|
||||
assert_eq!(gps[0].file_path, "2004/scan.jpg");
|
||||
let hit = dao
|
||||
.query_by_exif(None, Some("Canon"), None, None, None, None, false)
|
||||
.unwrap();
|
||||
assert_eq!(hit.len(), 1);
|
||||
let all = dao
|
||||
.query_by_exif(None, Some("Canon"), None, None, None, None, true)
|
||||
.unwrap();
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(
|
||||
dao.get_camera_makes().unwrap(),
|
||||
vec![("Canon".to_string(), 1)]
|
||||
);
|
||||
// /memories: the original's day, once.
|
||||
let window = |dao: &mut Box<dyn ExifDao>| {
|
||||
dao.get_memories_in_window(1, 0, 4_000_000_000, -1, 4_000_000_000, &[])
|
||||
.unwrap()
|
||||
};
|
||||
let rows = window(&mut dao);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].file_path, "2004/scan.jpg");
|
||||
drop(dao);
|
||||
|
||||
// Original deleted: both versions surface on the original's date.
|
||||
fx.forget("2004/scan.jpg");
|
||||
let mut dao = fx.exif.lock().unwrap();
|
||||
let rows = window(&mut dao);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(rows.iter().all(|r| r.date_utc == Some(1_500_000_000)));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn derived_work_never_sees_version_rows() {
|
||||
let (fx, original, v1, _v2) = seeded();
|
||||
// Same phash on original and version, as a restore would have.
|
||||
fx.exec("UPDATE image_exif SET phash_64 = 42, dhash_64 = 7, clip_embedding = x'00000000', clip_model_version = 'm'");
|
||||
let mut dao = fx.exif.lock().unwrap();
|
||||
let perceptual = dao.list_perceptual_candidates(None, true).unwrap();
|
||||
assert!(perceptual.iter().all(|r| r.content_hash != v1));
|
||||
assert!(perceptual.iter().any(|r| r.content_hash == original));
|
||||
let idx = dao.list_clip_index(&[], None).unwrap();
|
||||
assert!(idx.iter().all(|(h, _)| *h != v1));
|
||||
drop(dao);
|
||||
|
||||
fx.exec("UPDATE image_exif SET clip_embedding = NULL");
|
||||
let todo = fx
|
||||
.exif
|
||||
.lock()
|
||||
.unwrap()
|
||||
.list_clip_unencoded_candidates(1, 100)
|
||||
.unwrap();
|
||||
assert!(todo.iter().all(|(_, h)| *h != v1 && !h.is_empty()));
|
||||
assert_eq!(todo.len(), 2, "original + unrelated; neither version");
|
||||
let paths = fx
|
||||
.exif
|
||||
.lock()
|
||||
.unwrap()
|
||||
.list_image_paths(None, true)
|
||||
.unwrap();
|
||||
assert!(paths.iter().all(|(_, p)| !p.contains("aiedits")));
|
||||
}
|
||||
|
||||
// ── Endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn versions_endpoint_answers_for_original_or_any_version_path() {
|
||||
let (fx, original, v1, v2) = seeded();
|
||||
let app = photos_app!(fx);
|
||||
|
||||
for path in [
|
||||
"2004/scan.jpg",
|
||||
"2004/aiedits/scan.jpg.restored.png",
|
||||
"2004/aiedits/scan.jpg.edit.png",
|
||||
] {
|
||||
let (status, body) = get_json!(app, &format!("/image/versions?path={path}"));
|
||||
assert_eq!(status, 200, "{path}");
|
||||
assert_eq!(body["original"]["path"], "2004/scan.jpg");
|
||||
assert_eq!(body["original"]["hash"], original);
|
||||
assert_eq!(body["original"]["library_id"], 1);
|
||||
let versions = body["versions"].as_array().unwrap();
|
||||
assert_eq!(versions.len(), 2);
|
||||
let hashes: Vec<&str> = versions
|
||||
.iter()
|
||||
.map(|v| v["hash"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(hashes.contains(&v1.as_str()) && hashes.contains(&v2.as_str()));
|
||||
for key in [
|
||||
"path",
|
||||
"library_id",
|
||||
"kind",
|
||||
"source",
|
||||
"created_at",
|
||||
"references",
|
||||
] {
|
||||
assert!(versions[0].get(key).is_some(), "missing {key}");
|
||||
}
|
||||
assert!(body["display_hash"].is_null());
|
||||
}
|
||||
|
||||
let (status, _) = get_json!(app, "/image/versions?path=nope.jpg");
|
||||
assert_eq!(status, 404);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn display_choice_set_from_a_version_path_cleared_and_validated() {
|
||||
let (fx, original, v1, _v2) = seeded();
|
||||
let app = photos_app!(fx);
|
||||
let put = |body: serde_json::Value| {
|
||||
awtest::TestRequest::put()
|
||||
.uri("/image/versions/display")
|
||||
.insert_header(("Authorization", format!("Bearer {}", Fx::token())))
|
||||
.set_json(body)
|
||||
.to_request()
|
||||
};
|
||||
|
||||
// Set via a *version's* path, no library given.
|
||||
let resp = awtest::call_service(
|
||||
&app,
|
||||
put(serde_json::json!({"path": "2004/aiedits/scan.jpg.edit.png", "display_hash": v1})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = awtest::read_body_json(resp).await;
|
||||
assert_eq!(body["display_hash"], v1);
|
||||
|
||||
// Not a version of this photo.
|
||||
let resp = awtest::call_service(
|
||||
&app,
|
||||
put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": "f".repeat(64)})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
// Choosing the original clears it, as does null.
|
||||
let resp = awtest::call_service(
|
||||
&app,
|
||||
put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": original})),
|
||||
)
|
||||
.await;
|
||||
let body: serde_json::Value = awtest::read_body_json(resp).await;
|
||||
assert!(body["display_hash"].is_null());
|
||||
awtest::call_service(
|
||||
&app,
|
||||
put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": v1})),
|
||||
)
|
||||
.await;
|
||||
let resp = awtest::call_service(
|
||||
&app,
|
||||
put(serde_json::json!({"path": "2004/scan.jpg", "display_hash": null})),
|
||||
)
|
||||
.await;
|
||||
let body: serde_json::Value = awtest::read_body_json(resp).await;
|
||||
assert!(body["display_hash"].is_null());
|
||||
|
||||
// `library` may also ride in the query string (Apollo/FileViewer differ).
|
||||
let req = awtest::TestRequest::put()
|
||||
.uri("/image/versions/display?library=1")
|
||||
.insert_header(("Authorization", format!("Bearer {}", Fx::token())))
|
||||
.set_json(serde_json::json!({"path": "2004/scan.jpg", "display_hash": v1}))
|
||||
.to_request();
|
||||
assert_eq!(awtest::call_service(&app, req).await.status(), 200);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn deleting_the_displayed_version_falls_back_to_the_original() {
|
||||
let (fx, _o, v1, _v2) = seeded();
|
||||
fx.state
|
||||
.version_dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.set_display(&_o, Some(&v1))
|
||||
.unwrap();
|
||||
fx.forget("2004/aiedits/scan.jpg.restored.png");
|
||||
let app = photos_app!(fx);
|
||||
let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc");
|
||||
assert_eq!(body["photos"][0], "2004/scan.jpg");
|
||||
assert!(
|
||||
body.get("photo_display_paths").is_none(),
|
||||
"falls back to the original"
|
||||
);
|
||||
assert_eq!(body["photo_version_counts"][0], 1);
|
||||
let (_, body) = get_json!(app, "/image/versions?path=2004/scan.jpg");
|
||||
assert!(body["display_hash"].is_null());
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn a_version_serves_its_full_file() {
|
||||
let (fx, _o, _v1, _v2) = seeded();
|
||||
let app = awtest::init_service(
|
||||
App::new()
|
||||
.service(crate::handlers::image::get_image)
|
||||
.app_data(fx.state.clone())
|
||||
.app_data(fx.exif.clone()),
|
||||
)
|
||||
.await;
|
||||
let req = awtest::TestRequest::get()
|
||||
.uri("/image?path=2004/aiedits/scan.jpg.restored.png")
|
||||
.insert_header(("Authorization", format!("Bearer {}", Fx::token())))
|
||||
.to_request();
|
||||
let resp = awtest::call_service(&app, req).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let on_disk = std::fs::read(fx.root.join("2004/aiedits/scan.jpg.restored.png")).unwrap();
|
||||
assert_eq!(awtest::read_body(resp).await.as_ref(), on_disk.as_slice());
|
||||
}
|
||||
|
||||
// ── Tags and favorites ──────────────────────────────────────────────────
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn tags_and_favorites_on_a_version_path_bind_to_the_original() {
|
||||
let (fx, original, _v1, _v2) = seeded();
|
||||
fx.exec("INSERT INTO users (username, password) VALUES ('u', 'p')");
|
||||
let mut tags = SqliteTagDao::new(fx.conn.clone());
|
||||
let tag = tags.create_tag("holiday").unwrap();
|
||||
tags.tag_file("2004/aiedits/scan.jpg.restored.png", tag.id)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fx.count("SELECT COUNT(*) AS n FROM tagged_photo WHERE rel_path = '2004/scan.jpg'"),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
fx.count(&format!(
|
||||
"SELECT COUNT(*) AS n FROM tagged_photo WHERE content_hash = '{original}'"
|
||||
)),
|
||||
1,
|
||||
"bound to the original's hash"
|
||||
);
|
||||
// Read against either path.
|
||||
for p in ["2004/scan.jpg", "2004/aiedits/scan.jpg.edit.png"] {
|
||||
assert_eq!(tags.get_tags_for_path(p).unwrap().len(), 1, "{p}");
|
||||
}
|
||||
// The shared-content path set (what GET /image/tags expands) covers both.
|
||||
let sharing = fx
|
||||
.exif
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_rel_paths_sharing_content(1, "2004/aiedits/scan.jpg.edit.png")
|
||||
.unwrap();
|
||||
assert!(sharing.contains(&"2004/scan.jpg".to_string()));
|
||||
// Removing via a version path removes the original's tag.
|
||||
tags.remove_tag("holiday", "2004/aiedits/scan.jpg.restored.png")
|
||||
.unwrap();
|
||||
assert!(tags.get_tags_for_path("2004/scan.jpg").unwrap().is_empty());
|
||||
|
||||
let mut favs = SqliteFavoriteDao::from_shared(fx.conn.clone());
|
||||
favs.add_favorite(1, "2004/aiedits/scan.jpg.restored.png")
|
||||
.unwrap();
|
||||
let listed: Vec<String> = favs
|
||||
.get_favorites(1)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|f| f.path)
|
||||
.collect();
|
||||
assert_eq!(listed, vec!["2004/scan.jpg"]);
|
||||
favs.remove_favorite(1, "2004/aiedits/scan.jpg.edit.png".to_string());
|
||||
assert!(favs.get_favorites(1).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn tags_bind_to_the_version_itself_once_the_original_is_gone() {
|
||||
let (fx, _o, _v1, _v2) = seeded();
|
||||
fx.forget("2004/scan.jpg");
|
||||
let mut tags = SqliteTagDao::new(fx.conn.clone());
|
||||
let tag = tags.create_tag("keeper").unwrap();
|
||||
tags.tag_file("2004/aiedits/scan.jpg.edit.png", tag.id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tags.get_tags_for_path("2004/aiedits/scan.jpg.edit.png")
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────────
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn orphan_gc_retires_a_gone_version_but_keeps_links_of_a_gone_original() {
|
||||
let (fx, original, v1, v2) = seeded();
|
||||
fx.state
|
||||
.version_dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.set_display(&original, Some(&v1))
|
||||
.unwrap();
|
||||
|
||||
// Version v1's file disappears.
|
||||
fx.forget("2004/aiedits/scan.jpg.restored.png");
|
||||
let mut conn = fx.conn.lock().unwrap();
|
||||
let mut state = library_maintenance::OrphanGcState::default();
|
||||
library_maintenance::run_orphan_gc(&mut conn, &mut state, true);
|
||||
let stats = library_maintenance::run_orphan_gc(&mut conn, &mut state, true);
|
||||
assert_eq!(stats.deleted_photo_versions, 2, "link + display row");
|
||||
drop(conn);
|
||||
assert_eq!(fx.count("SELECT COUNT(*) AS n FROM photo_versions"), 1);
|
||||
assert_eq!(
|
||||
fx.count("SELECT COUNT(*) AS n FROM photo_version_display"),
|
||||
0
|
||||
);
|
||||
|
||||
// The original goes too: the remaining link is kept (the version is a
|
||||
// standalone photo, and the link revives if the original returns).
|
||||
fx.forget("2004/scan.jpg");
|
||||
let mut conn = fx.conn.lock().unwrap();
|
||||
for _ in 0..3 {
|
||||
library_maintenance::run_orphan_gc(&mut conn, &mut state, true);
|
||||
}
|
||||
drop(conn);
|
||||
assert_eq!(
|
||||
fx.count(&format!(
|
||||
"SELECT COUNT(*) AS n FROM photo_versions WHERE version_hash = '{v2}'"
|
||||
)),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn back_ref_refresh_follows_a_moved_version_file() {
|
||||
let (fx, _o, v1, _v2) = seeded();
|
||||
// The file moved: old row retired, new row appears elsewhere.
|
||||
fx.forget("2004/aiedits/scan.jpg.restored.png");
|
||||
fx.exec(&format!(
|
||||
"INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash) \
|
||||
VALUES (1, 'archive/scan.jpg.restored.png', 0, 0, '{v1}')"
|
||||
));
|
||||
let mut conn = fx.conn.lock().unwrap();
|
||||
assert!(library_maintenance::refresh_back_refs(&mut conn) >= 1);
|
||||
drop(conn);
|
||||
assert_eq!(
|
||||
fx.count(&format!(
|
||||
"SELECT COUNT(*) AS n FROM photo_versions \
|
||||
WHERE version_hash = '{v1}' AND rel_path = 'archive/scan.jpg.restored.png'"
|
||||
)),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn face_stats_denominator_leaves_versions_out() {
|
||||
let (fx, _o, _v1, _v2) = seeded();
|
||||
let hidden = fx
|
||||
.state
|
||||
.version_dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.hidden_version_rows(&[])
|
||||
.unwrap();
|
||||
assert_eq!(hidden.len(), 2);
|
||||
let mut faces = crate::faces::SqliteFaceDao::from_connection(fx.conn.clone());
|
||||
use crate::faces::FaceDao;
|
||||
let stats = faces.stats(None).unwrap();
|
||||
// scan.jpg + unrelated.jpg; the two .png versions stay out of the
|
||||
// denominator because they are never scanned.
|
||||
assert_eq!(stats.total_photos, 2);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn linking_at_ingest_drops_derived_data_of_an_already_ingested_version() {
|
||||
let fx = fx();
|
||||
let original = fx.put("scan.jpg", b"original bytes");
|
||||
let xmp = producer_xmp(&original, "restored", &[]);
|
||||
let vh = fx.put_png("renamed.png", Some(&xmp), 1);
|
||||
fx.exec(&format!(
|
||||
"UPDATE image_exif SET clip_embedding = x'00', clip_model_version = 'm' \
|
||||
WHERE content_hash = '{vh}'"
|
||||
));
|
||||
fx.exec(&format!(
|
||||
"INSERT INTO face_detections (library_id, content_hash, rel_path, bbox_x, embedding, \
|
||||
source, status, model_version, created_at) \
|
||||
VALUES (1, '{vh}', 'renamed.png', 0.1, x'00', 'auto', 'detected', 'm', 0)"
|
||||
));
|
||||
fx.exec(&format!(
|
||||
"INSERT INTO clip_failures (library_id, rel_path, content_hash, error, failed_at) \
|
||||
VALUES (1, 'renamed.png', '{vh}', 'boom', 0)"
|
||||
));
|
||||
|
||||
let files = vec![(fx.root.join("renamed.png"), "renamed.png".to_string())];
|
||||
assert_eq!(
|
||||
link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
fx.count(&format!(
|
||||
"SELECT COUNT(*) AS n FROM face_detections WHERE content_hash = '{vh}'"
|
||||
)),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
fx.count(&format!(
|
||||
"SELECT COUNT(*) AS n FROM clip_failures WHERE content_hash = '{vh}'"
|
||||
)),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
fx.count(&format!(
|
||||
"SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{vh}' \
|
||||
AND (clip_embedding IS NOT NULL OR clip_model_version IS NOT NULL)"
|
||||
)),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn link_new_files_links_across_more_than_one_lookup_chunk() {
|
||||
let fx = fx();
|
||||
let original = fx.put("scan.jpg", b"original bytes");
|
||||
let xmp = producer_xmp(&original, "restored", &[]);
|
||||
let vh = fx.put_png("zz/renamed.png", Some(&xmp), 1);
|
||||
// Enough unrelated PNG paths to force several lookup chunks; the real
|
||||
// one sits in the last.
|
||||
let mut files: Vec<(PathBuf, String)> = (0..1200)
|
||||
.map(|i| (fx.root.join(format!("n/{i}.png")), format!("n/{i}.png")))
|
||||
.collect();
|
||||
files.push((fx.root.join("zz/renamed.png"), "zz/renamed.png".to_string()));
|
||||
assert_eq!(
|
||||
link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
fx.state
|
||||
.version_dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_link(&vh)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn display_choice_needs_a_file_in_the_originals_library() {
|
||||
let fx = fx();
|
||||
let original = fx.put("scan.jpg", b"original bytes");
|
||||
let xmp = producer_xmp(&original, "restored", &[]);
|
||||
let vh = fx.put_png("aiedits/scan.jpg.restored.png", Some(&xmp), 1);
|
||||
fx.link(&vh, &original, "aiedits/scan.jpg.restored.png");
|
||||
// The version's bytes live only in a second library.
|
||||
fx.exec("INSERT INTO libraries (id, name, root_path, created_at) VALUES (2, 'two', '/tmp/two-nope', 0)");
|
||||
fx.exec(
|
||||
"UPDATE image_exif SET library_id = 2 WHERE rel_path = 'aiedits/scan.jpg.restored.png'",
|
||||
);
|
||||
let app = photos_app!(fx);
|
||||
let req = awtest::TestRequest::put()
|
||||
.uri("/image/versions/display")
|
||||
.insert_header(("Authorization", format!("Bearer {}", Fx::token())))
|
||||
.set_json(serde_json::json!({"path": "scan.jpg", "display_hash": vh}))
|
||||
.to_request();
|
||||
assert_eq!(awtest::call_service(&app, req).await.status(), 400);
|
||||
// Clearing is still fine.
|
||||
let req = awtest::TestRequest::put()
|
||||
.uri("/image/versions/display")
|
||||
.insert_header(("Authorization", format!("Bearer {}", Fx::token())))
|
||||
.set_json(serde_json::json!({"path": "scan.jpg", "display_hash": null}))
|
||||
.to_request();
|
||||
assert_eq!(awtest::call_service(&app, req).await.status(), 200);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! Photo versions (#191): AI restorations / upscales / edits that sit near
|
||||
//! their original and are shown as one photo.
|
||||
//!
|
||||
//! ImageApi only *detects* versions; an external process (the ComfyUI node
|
||||
//! in `comfyui-imageapi-versions`) writes them into the library, so the
|
||||
//! read-only rule for library roots holds. This module owns
|
||||
//!
|
||||
//! - the link rules at ingest ([`link_new_files`], [`sweep_name_links`]):
|
||||
//! the PNG's XMP `DerivedFrom` hash first, then the
|
||||
//! `<name>.<kind>[.<n>].<ext>` file-name convention;
|
||||
//! - the HTTP surface (`GET /image/versions`, `PUT /image/versions/display`).
|
||||
//!
|
||||
//! The data model lives in [`crate::database::version_dao`]. The link is the
|
||||
//! original's `content_hash`, never a path: ComfyUI writes through a UNC
|
||||
//! share and ImageApi reads `/media/...`, and files get moved.
|
||||
|
||||
pub mod backfill;
|
||||
pub mod handlers;
|
||||
pub mod xmp;
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
use log::{debug, info, warn};
|
||||
use regex::Regex;
|
||||
|
||||
use crate::database::version_dao::{
|
||||
KINDS, LinkOutcome, NewVersionLink, SOURCE_NAME, SOURCE_XMP, VersionDao, is_known_kind,
|
||||
};
|
||||
use crate::database::{ExifDao, version_dao};
|
||||
use crate::libraries::Library;
|
||||
|
||||
pub use handlers::{get_versions, put_display};
|
||||
|
||||
/// Directory the producer saves versions into, next to the original.
|
||||
pub const VERSIONS_SUBFOLDER: &str = "aiedits";
|
||||
|
||||
/// Cap on name-convention candidates examined per library per tick.
|
||||
const NAME_SWEEP_LIMIT: i64 = 500;
|
||||
|
||||
static VERSION_NAME: LazyLock<Regex> = LazyLock::new(|| {
|
||||
let kinds = KINDS.join("|");
|
||||
Regex::new(&format!(
|
||||
r"(?i)^(?P<name>.+)\.(?P<kind>{kinds})(?:\.(?P<n>\d+))?\.(?P<ext>[a-z0-9]+)$"
|
||||
))
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
/// A file name that follows the producer's `<original name>.<kind>[.<n>].<ext>`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VersionName {
|
||||
/// The original's full file name, extension included.
|
||||
pub original_name: String,
|
||||
pub kind: &'static str,
|
||||
}
|
||||
|
||||
/// Parse a bare file name (no directory). The original's extension stays in
|
||||
/// `original_name`, so `IMG_1.NEF` and `IMG_1.JPG` are told apart.
|
||||
pub fn parse_version_name(file_name: &str) -> Option<VersionName> {
|
||||
let caps = VERSION_NAME.captures(file_name)?;
|
||||
let kind_text = caps["kind"].to_ascii_lowercase();
|
||||
let kind = KINDS.iter().find(|k| **k == kind_text)?;
|
||||
Some(VersionName {
|
||||
original_name: caps["name"].to_string(),
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rel paths where the original of a name-convention file may sit, best
|
||||
/// first: the same directory, then the parent of an `aiedits/` directory.
|
||||
pub fn original_candidates(rel_path: &str) -> Vec<String> {
|
||||
let (dir, file_name) = match rel_path.rsplit_once('/') {
|
||||
Some((d, f)) => (d, f),
|
||||
None => ("", rel_path),
|
||||
};
|
||||
let Some(parsed) = parse_version_name(file_name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let join = |d: &str| {
|
||||
if d.is_empty() {
|
||||
parsed.original_name.clone()
|
||||
} else {
|
||||
format!("{d}/{}", parsed.original_name)
|
||||
}
|
||||
};
|
||||
let mut out = vec![join(dir)];
|
||||
let (parent, leaf) = match dir.rsplit_once('/') {
|
||||
Some((p, l)) => (p, l),
|
||||
None => ("", dir),
|
||||
};
|
||||
if leaf.eq_ignore_ascii_case(VERSIONS_SUBFOLDER) {
|
||||
out.push(join(parent));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve the name-convention link of `rel_path` (in `library`) against the
|
||||
/// `image_exif` rows of that library. `None` when the name doesn't follow the
|
||||
/// convention or no candidate original has a hash yet (the sweep retries).
|
||||
pub fn name_link(
|
||||
library: &Library,
|
||||
rel_path: &str,
|
||||
version_hash: &str,
|
||||
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> Option<NewVersionLink> {
|
||||
let file_name = rel_path.rsplit('/').next().unwrap_or(rel_path);
|
||||
let kind = parse_version_name(file_name)?.kind;
|
||||
let candidates = original_candidates(rel_path);
|
||||
let rows = exif_dao
|
||||
.lock()
|
||||
.expect("Unable to lock ExifDao")
|
||||
.get_exif_batch(Some(library.id), &candidates)
|
||||
.ok()?;
|
||||
// First candidate (in preference order) that has a hash of its own.
|
||||
let original_hash = candidates.iter().find_map(|c| {
|
||||
rows.iter()
|
||||
.find(|r| &r.file_path == c)
|
||||
.and_then(|r| r.content_hash.clone())
|
||||
.filter(|h| h != version_hash)
|
||||
})?;
|
||||
Some(NewVersionLink {
|
||||
version_hash: version_hash.to_string(),
|
||||
original_hash,
|
||||
kind: kind.to_string(),
|
||||
source: SOURCE_NAME.to_string(),
|
||||
library_id: library.id,
|
||||
rel_path: rel_path.to_string(),
|
||||
references: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the XMP link of a PNG on disk. `None` when the file isn't a PNG,
|
||||
/// can't be read, or carries no `DerivedFrom` blake3 id.
|
||||
pub fn xmp_link(
|
||||
library: &Library,
|
||||
rel_path: &str,
|
||||
abs_path: &Path,
|
||||
version_hash: &str,
|
||||
) -> Option<NewVersionLink> {
|
||||
let is_png = abs_path
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("png"));
|
||||
if !is_png {
|
||||
return None;
|
||||
}
|
||||
let parsed = match xmp::read_version_xmp(abs_path) {
|
||||
Ok(p) => p?,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"versions: could not read XMP of {}: {}",
|
||||
abs_path.display(),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let file_name = rel_path.rsplit('/').next().unwrap_or(rel_path);
|
||||
let kind = parsed
|
||||
.kind
|
||||
.filter(|k| is_known_kind(k))
|
||||
.or_else(|| parse_version_name(file_name).map(|n| n.kind.to_string()))
|
||||
.unwrap_or_else(|| "edit".to_string());
|
||||
Some(NewVersionLink {
|
||||
version_hash: version_hash.to_string(),
|
||||
original_hash: parsed.original_hash,
|
||||
kind,
|
||||
source: SOURCE_XMP.to_string(),
|
||||
library_id: library.id,
|
||||
rel_path: rel_path.to_string(),
|
||||
references: parsed.references,
|
||||
})
|
||||
}
|
||||
|
||||
/// Record a link, logging the outcome. Returns true if a new link was made.
|
||||
pub fn record_link(version_dao: &Arc<Mutex<Box<dyn VersionDao>>>, link: &NewVersionLink) -> bool {
|
||||
let outcome = version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.link_version(link);
|
||||
match outcome {
|
||||
Ok(LinkOutcome::Linked) => {
|
||||
info!(
|
||||
"versions: linked {} -> original {}.. ({}, {})",
|
||||
link.rel_path,
|
||||
&link.original_hash[..12.min(link.original_hash.len())],
|
||||
link.kind,
|
||||
link.source
|
||||
);
|
||||
true
|
||||
}
|
||||
Ok(LinkOutcome::AlreadyLinked) => false,
|
||||
Ok(LinkOutcome::Rejected(why)) => {
|
||||
warn!("versions: refused link for {}: {}", link.rel_path, why);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("versions: link failed for {}: {:?}", link.rel_path, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Link freshly ingested files by their embedded XMP. `files` are the
|
||||
/// `(absolute, rel_path)` pairs the watcher just registered; rows without a
|
||||
/// hash yet are skipped (an XMP-less file is the name sweep's job anyway).
|
||||
/// Returns the number of new links.
|
||||
pub fn link_new_files(
|
||||
library: &Library,
|
||||
files: &[(PathBuf, String)],
|
||||
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
version_dao: &Arc<Mutex<Box<dyn VersionDao>>>,
|
||||
) -> usize {
|
||||
let pngs: Vec<&(PathBuf, String)> = files
|
||||
.iter()
|
||||
.filter(|(p, _)| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("png")))
|
||||
.collect();
|
||||
if pngs.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let rels: Vec<String> = pngs.iter().map(|(_, r)| r.clone()).collect();
|
||||
// Chunked: a first scan can register far more paths than SQLite's
|
||||
// bound-variable limit allows in one `IN (...)`.
|
||||
let mut rows = Vec::with_capacity(rels.len());
|
||||
for chunk in rels.chunks(500) {
|
||||
match exif_dao
|
||||
.lock()
|
||||
.expect("Unable to lock ExifDao")
|
||||
.get_exif_batch(Some(library.id), chunk)
|
||||
{
|
||||
Ok(r) => rows.extend(r),
|
||||
Err(e) => warn!(
|
||||
"versions: XMP link lookup failed for {} paths in '{}': {:?}",
|
||||
chunk.len(),
|
||||
library.name,
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
let hashes: std::collections::HashMap<&str, &str> = rows
|
||||
.iter()
|
||||
.filter_map(|r| Some((r.file_path.as_str(), r.content_hash.as_deref()?)))
|
||||
.collect();
|
||||
|
||||
let mut linked = 0;
|
||||
for (abs, rel) in pngs {
|
||||
let Some(hash) = hashes.get(rel.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(link) = xmp_link(library, rel, abs, hash)
|
||||
&& record_link(version_dao, &link)
|
||||
{
|
||||
linked += 1;
|
||||
}
|
||||
}
|
||||
linked
|
||||
}
|
||||
|
||||
/// Link name-convention files whose original is (now) known. Idempotent and
|
||||
/// DB-only, so it also serves as the retry for "original not hashed yet" and
|
||||
/// "original ingested after the version". Returns the number of new links.
|
||||
pub fn sweep_name_links(
|
||||
library: &Library,
|
||||
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
version_dao: &Arc<Mutex<Box<dyn VersionDao>>>,
|
||||
) -> usize {
|
||||
let candidates = match version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.unlinked_name_candidates(library.id, NAME_SWEEP_LIMIT)
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"versions: name sweep failed for '{}': {:?}",
|
||||
library.name, e
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
let mut linked = 0;
|
||||
for (rel, hash) in candidates {
|
||||
if let Some(link) = name_link(library, &rel, &hash, exif_dao)
|
||||
&& record_link(version_dao, &link)
|
||||
{
|
||||
linked += 1;
|
||||
}
|
||||
}
|
||||
linked
|
||||
}
|
||||
|
||||
/// Sweep names and invalidate what a new link makes stale. The watcher calls
|
||||
/// this once per library per tick, after ingest.
|
||||
pub fn link_pending(
|
||||
library: &Library,
|
||||
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
version_dao: &Arc<Mutex<Box<dyn VersionDao>>>,
|
||||
) {
|
||||
if sweep_name_links(library, exif_dao, version_dao) > 0 {
|
||||
crate::duplicates::invalidate_caches();
|
||||
}
|
||||
}
|
||||
|
||||
/// The version facts a per-photo record (a memory, a search hit) carries.
|
||||
/// Flattened into the record; absent for a photo without versions, so
|
||||
/// responses stay byte-identical for libraries that have none.
|
||||
///
|
||||
/// `display_path` is `null` when the original is displayed, a rel_path when a
|
||||
/// version is. `/photos` carries the same facts as the parallel arrays
|
||||
/// `photo_display_paths` / `photo_version_counts` instead.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
pub struct VersionFields {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_path: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version_count: Option<u32>,
|
||||
}
|
||||
|
||||
impl VersionFields {
|
||||
pub fn from_summary(summary: Option<&version_dao::PhotoVersionSummary>) -> Self {
|
||||
match summary {
|
||||
Some(s) if s.version_count > 0 => VersionFields {
|
||||
display_path: Some(s.display.as_ref().map(|(_, p)| p.clone())),
|
||||
version_count: Some(s.version_count),
|
||||
},
|
||||
_ => VersionFields::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the per-photo version summaries once for a response. Empty (and
|
||||
/// cheap) when nothing has versions; a DB failure degrades to "no versions"
|
||||
/// rather than failing the listing.
|
||||
pub fn load_summaries(
|
||||
version_dao: &Mutex<Box<dyn VersionDao>>,
|
||||
) -> std::collections::HashMap<(i32, String), version_dao::PhotoVersionSummary> {
|
||||
version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.photo_summaries()
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("versions: could not load summaries: {:?}", e);
|
||||
Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_the_producer_naming_convention() {
|
||||
let n = parse_version_name("scan_0042.jpg.restored.png").unwrap();
|
||||
assert_eq!(n.original_name, "scan_0042.jpg");
|
||||
assert_eq!(n.kind, "restored");
|
||||
let n = parse_version_name("scan_0042.jpg.restored.2.png").unwrap();
|
||||
assert_eq!(n.original_name, "scan_0042.jpg");
|
||||
let n = parse_version_name("IMG_1.NEF.upscale.png").unwrap();
|
||||
assert_eq!(n.original_name, "IMG_1.NEF");
|
||||
assert_eq!(n.kind, "upscale");
|
||||
assert_eq!(
|
||||
parse_version_name("a.b.c.EDIT.12.PNG")
|
||||
.unwrap()
|
||||
.original_name,
|
||||
"a.b.c"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_names_outside_the_convention() {
|
||||
assert!(parse_version_name("holiday.png").is_none());
|
||||
assert!(parse_version_name("restored.png").is_none());
|
||||
assert!(parse_version_name("x.jpg.blurred.png").is_none());
|
||||
assert!(parse_version_name("x.jpg.restored").is_none());
|
||||
assert!(parse_version_name("x.restored.png.bak").is_none());
|
||||
assert!(parse_version_name(".restored.png").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_fields_are_skipped_without_versions_and_null_without_a_display_choice() {
|
||||
#[derive(serde::Serialize)]
|
||||
struct Rec {
|
||||
path: &'static str,
|
||||
#[serde(flatten)]
|
||||
versions: VersionFields,
|
||||
}
|
||||
let json = |summary: Option<&version_dao::PhotoVersionSummary>| {
|
||||
serde_json::to_value(Rec {
|
||||
path: "a.jpg",
|
||||
versions: VersionFields::from_summary(summary),
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(json(None), serde_json::json!({"path": "a.jpg"}));
|
||||
let plain = version_dao::PhotoVersionSummary {
|
||||
version_count: 2,
|
||||
display: None,
|
||||
};
|
||||
assert_eq!(
|
||||
json(Some(&plain)),
|
||||
serde_json::json!({"path": "a.jpg", "display_path": null, "version_count": 2})
|
||||
);
|
||||
let shown = version_dao::PhotoVersionSummary {
|
||||
version_count: 1,
|
||||
display: Some((1, "aiedits/a.jpg.restored.png".into())),
|
||||
};
|
||||
assert_eq!(
|
||||
json(Some(&shown)),
|
||||
serde_json::json!({
|
||||
"path": "a.jpg",
|
||||
"display_path": "aiedits/a.jpg.restored.png",
|
||||
"version_count": 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_prefer_the_same_dir_then_the_parent_of_aiedits() {
|
||||
assert_eq!(
|
||||
original_candidates("2004/aiedits/scan.jpg.restored.png"),
|
||||
vec!["2004/aiedits/scan.jpg", "2004/scan.jpg"]
|
||||
);
|
||||
assert_eq!(
|
||||
original_candidates("2004/scan.jpg.restored.png"),
|
||||
vec!["2004/scan.jpg"]
|
||||
);
|
||||
assert_eq!(
|
||||
original_candidates("aiedits/scan.jpg.edit.3.png"),
|
||||
vec!["aiedits/scan.jpg", "scan.jpg"]
|
||||
);
|
||||
assert_eq!(
|
||||
original_candidates("scan.jpg.upscale.png"),
|
||||
vec!["scan.jpg"]
|
||||
);
|
||||
assert!(original_candidates("2004/plain.png").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Reading the version link out of a PNG: the `XML:com.adobe.xmp` iTXt
|
||||
//! chunk the ComfyUI producer writes (`comfyui-imageapi-versions`), and the
|
||||
//! ComfyUI `prompt` tEXt chunk the backfill uses for files without XMP.
|
||||
//!
|
||||
//! The chunk stream is walked by hand: no pixel decode, and the `png`
|
||||
//! crate's text API would still inflate every IDAT's neighbours for no gain.
|
||||
//! IDAT payloads are seeked over, so a multi-megabyte restoration costs a
|
||||
//! handful of small reads on a network share.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufReader, Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// iTXt keyword exiftool, Lightroom and the producer put XMP under.
|
||||
pub const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
|
||||
|
||||
/// Text chunks bigger than this are skipped rather than read: ComfyUI's
|
||||
/// `prompt` / `workflow` JSON is tens of KiB, an XMP packet around 1 KiB.
|
||||
const MAX_TEXT_CHUNK: u32 = 4 * 1024 * 1024;
|
||||
|
||||
const PNG_SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
/// What a version PNG's XMP says about its provenance.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VersionXmp {
|
||||
/// `xmpMM:DerivedFrom/stRef:documentID`: the original's `content_hash`.
|
||||
pub original_hash: String,
|
||||
/// `xmpMM:Ingredients[]/stRef:documentID`: reference photos.
|
||||
pub references: Vec<String>,
|
||||
/// `imageapi:versionKind`, as written (not yet validated).
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
/// Value of the first text chunk (`tEXt` or uncompressed `iTXt`) whose
|
||||
/// keyword is in `keywords`, for each keyword found. Compressed chunks are
|
||||
/// skipped (the producer never compresses). `Ok(vec![])` for a file that
|
||||
/// isn't a PNG.
|
||||
pub fn read_png_text(path: &Path, keywords: &[&str]) -> io::Result<Vec<(String, String)>> {
|
||||
let mut reader = BufReader::new(File::open(path)?);
|
||||
let mut sig = [0u8; 8];
|
||||
if reader.read_exact(&mut sig).is_err() || sig != PNG_SIGNATURE {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut found: Vec<(String, String)> = Vec::new();
|
||||
loop {
|
||||
let mut head = [0u8; 8];
|
||||
if reader.read_exact(&mut head).is_err() {
|
||||
break; // truncated file: use what we have
|
||||
}
|
||||
let len = u32::from_be_bytes([head[0], head[1], head[2], head[3]]);
|
||||
let kind = &head[4..8];
|
||||
let is_text = kind == b"tEXt" || kind == b"iTXt";
|
||||
if is_text && len <= MAX_TEXT_CHUNK {
|
||||
let mut data = vec![0u8; len as usize];
|
||||
if reader.read_exact(&mut data).is_err() {
|
||||
break;
|
||||
}
|
||||
let parsed = if kind == b"tEXt" {
|
||||
parse_text(&data)
|
||||
} else {
|
||||
parse_itxt(&data)
|
||||
};
|
||||
if let Some((key, value)) = parsed
|
||||
&& keywords.contains(&key.as_str())
|
||||
&& !found.iter().any(|(k, _)| *k == key)
|
||||
{
|
||||
found.push((key, value));
|
||||
if found.len() == keywords.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
reader.seek_relative(4)?; // CRC
|
||||
} else {
|
||||
reader.seek(SeekFrom::Current(i64::from(len) + 4))?;
|
||||
}
|
||||
if kind == b"IEND" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// `keyword\0text` (Latin-1).
|
||||
fn parse_text(data: &[u8]) -> Option<(String, String)> {
|
||||
let nul = data.iter().position(|b| *b == 0)?;
|
||||
let key = String::from_utf8_lossy(&data[..nul]).into_owned();
|
||||
let value: String = data[nul + 1..].iter().map(|b| *b as char).collect();
|
||||
Some((key, value))
|
||||
}
|
||||
|
||||
/// `keyword\0 compressed(1) method(1) lang\0 translated\0 text` (UTF-8);
|
||||
/// `None` for compressed chunks.
|
||||
fn parse_itxt(data: &[u8]) -> Option<(String, String)> {
|
||||
let nul = data.iter().position(|b| *b == 0)?;
|
||||
let key = String::from_utf8_lossy(&data[..nul]).into_owned();
|
||||
let rest = data.get(nul + 1..)?;
|
||||
if *rest.first()? != 0 {
|
||||
return None;
|
||||
}
|
||||
let rest = rest.get(2..)?; // flag + method
|
||||
let lang_end = rest.iter().position(|b| *b == 0)?;
|
||||
let rest = &rest[lang_end + 1..];
|
||||
let translated_end = rest.iter().position(|b| *b == 0)?;
|
||||
let text = &rest[translated_end + 1..];
|
||||
Some((key, String::from_utf8_lossy(text).into_owned()))
|
||||
}
|
||||
|
||||
/// The XMP packet of a PNG, if it has one.
|
||||
pub fn read_png_xmp(path: &Path) -> io::Result<Option<String>> {
|
||||
Ok(read_png_text(path, &[XMP_KEYWORD])?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(_, v)| v))
|
||||
}
|
||||
|
||||
static DERIVED_FROM: LazyLock<Regex> = LazyLock::new(|| {
|
||||
// Element form (`<xmpMM:DerivedFrom ...>...</xmpMM:DerivedFrom>`) or the
|
||||
// attribute form exiftool may write (`<xmpMM:DerivedFrom stRef:documentID=".."/>`).
|
||||
Regex::new(r"(?s)<xmpMM:DerivedFrom\b([^>]*?)(?:/>|>(.*?)</xmpMM:DerivedFrom>)").unwrap()
|
||||
});
|
||||
static INGREDIENTS: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?s)<xmpMM:Ingredients\b[^>]*>(.*?)</xmpMM:Ingredients>").unwrap()
|
||||
});
|
||||
static DOCUMENT_ID: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"documentID(?:\s*=\s*["']|\s*>\s*)blake3:([0-9a-fA-F]{64})"#).unwrap()
|
||||
});
|
||||
static VERSION_KIND: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"imageapi:versionKind(?:\s*=\s*["']|\s*>\s*)([A-Za-z]+)"#).unwrap()
|
||||
});
|
||||
|
||||
/// Parse the link fields out of an XMP packet. `None` when it has no
|
||||
/// `DerivedFrom` blake3 id (an ordinary photo's XMP, or someone else's).
|
||||
pub fn parse_version_xmp(xmp: &str) -> Option<VersionXmp> {
|
||||
let derived = DERIVED_FROM.captures(xmp)?;
|
||||
let region = format!(
|
||||
"{} {}",
|
||||
derived.get(1).map_or("", |m| m.as_str()),
|
||||
derived.get(2).map_or("", |m| m.as_str())
|
||||
);
|
||||
let original_hash = DOCUMENT_ID.captures(®ion)?[1].to_ascii_lowercase();
|
||||
|
||||
let mut references: Vec<String> = Vec::new();
|
||||
if let Some(ing) = INGREDIENTS.captures(xmp) {
|
||||
for cap in DOCUMENT_ID.captures_iter(&ing[1]) {
|
||||
let h = cap[1].to_ascii_lowercase();
|
||||
if !references.contains(&h) {
|
||||
references.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let kind = VERSION_KIND
|
||||
.captures(xmp)
|
||||
.map(|c| c[1].to_ascii_lowercase());
|
||||
Some(VersionXmp {
|
||||
original_hash,
|
||||
references,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the version link out of a PNG file; `None` if it has none.
|
||||
pub fn read_version_xmp(path: &Path) -> io::Result<Option<VersionXmp>> {
|
||||
Ok(read_png_xmp(path)?.and_then(|x| parse_version_xmp(&x)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_png {
|
||||
use std::io::Write;
|
||||
|
||||
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(&[0, 0, 0, 0]); // CRC: the reader never checks it
|
||||
}
|
||||
|
||||
/// A PNG-shaped byte stream: signature, IHDR, optional tEXt `prompt`,
|
||||
/// optional XMP iTXt, an IDAT, IEND. Not decodable; enough for the
|
||||
/// chunk walker (and unique per `salt`, so each file hashes differently).
|
||||
pub fn png_bytes(xmp: Option<&str>, prompt: Option<&str>, salt: u8) -> Vec<u8> {
|
||||
let mut out = super::PNG_SIGNATURE.to_vec();
|
||||
chunk(&mut out, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0]);
|
||||
if let Some(p) = prompt {
|
||||
let mut d = b"prompt\0".to_vec();
|
||||
d.extend_from_slice(p.as_bytes());
|
||||
chunk(&mut out, b"tEXt", &d);
|
||||
}
|
||||
if let Some(x) = xmp {
|
||||
let mut d = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec();
|
||||
d.extend_from_slice(x.as_bytes());
|
||||
chunk(&mut out, b"iTXt", &d);
|
||||
}
|
||||
chunk(&mut out, b"IDAT", &[salt; 32]);
|
||||
chunk(&mut out, b"IEND", &[]);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write_png(path: &std::path::Path, xmp: Option<&str>, prompt: Option<&str>, salt: u8) {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
std::fs::File::create(path)
|
||||
.unwrap()
|
||||
.write_all(&png_bytes(xmp, prompt, salt))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The packet the producer writes, built the way `versioning.build_xmp` does.
|
||||
pub fn producer_xmp(original: &str, kind: &str, references: &[&str]) -> String {
|
||||
let ingredients = if references.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let items: String = references
|
||||
.iter()
|
||||
.map(|h| {
|
||||
format!(
|
||||
"<rdf:li rdf:parseType=\"Resource\"><stRef:documentID>blake3:{h}</stRef:documentID>\
|
||||
<stRef:filePath>ref.jpg</stRef:filePath></rdf:li>"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!("<xmpMM:Ingredients><rdf:Bag>{items}</rdf:Bag></xmpMM:Ingredients>")
|
||||
};
|
||||
format!(
|
||||
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\
|
||||
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description rdf:about=\"\" \
|
||||
xmlns:xmpMM=\"http://ns.adobe.com/xap/1.0/mm/\" xmlns:stRef=\"http://ns.adobe.com/xap/1.0/sType/ResourceRef#\" \
|
||||
xmlns:imageapi=\"https://git.crkc.xyz/Apps/ImageApi/ns/versions/1.0/\">\
|
||||
<xmpMM:DerivedFrom rdf:parseType=\"Resource\"><stRef:documentID>blake3:{original}</stRef:documentID>\
|
||||
<stRef:filePath>orig.jpg</stRef:filePath></xmpMM:DerivedFrom>{ingredients}\
|
||||
<imageapi:versionKind>{kind}</imageapi:versionKind></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::test_png::*;
|
||||
use super::*;
|
||||
|
||||
const ORIG: &str = "d5fa14088629f680f4f8e5815df473cc4684a97fc937da588baf72d2693dea3a";
|
||||
const REF: &str = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
|
||||
/// The packet embedded in a real archive restoration (copied verbatim).
|
||||
const REAL_XMP: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" xmlns:imageapi="https://git.crkc.xyz/Apps/ImageApi/ns/versions/1.0/"><xmpMM:DerivedFrom rdf:parseType="Resource"><stRef:documentID>blake3:d5fa14088629f680f4f8e5815df473cc4684a97fc937da588baf72d2693dea3a</stRef:documentID><stRef:filePath>019_19.JPG</stRef:filePath></xmpMM:DerivedFrom><imageapi:versionKind>restored</imageapi:versionKind></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#;
|
||||
|
||||
#[test]
|
||||
fn parses_the_real_producer_packet() {
|
||||
let x = parse_version_xmp(REAL_XMP).unwrap();
|
||||
assert_eq!(x.original_hash, ORIG);
|
||||
assert_eq!(x.kind.as_deref(), Some("restored"));
|
||||
assert!(x.references.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_references_and_keeps_them_apart_from_the_original() {
|
||||
let x = parse_version_xmp(&producer_xmp(ORIG, "edit", &[REF])).unwrap();
|
||||
assert_eq!(x.original_hash, ORIG);
|
||||
assert_eq!(x.references, vec![REF]);
|
||||
assert_eq!(x.kind.as_deref(), Some("edit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_the_attribute_form_other_writers_use() {
|
||||
let xmp = format!(
|
||||
r#"<x:xmpmeta><rdf:RDF><rdf:Description><xmpMM:DerivedFrom stRef:documentID="blake3:{ORIG}" stRef:filePath="a.jpg"/></rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||||
);
|
||||
assert_eq!(parse_version_xmp(&xmp).unwrap().original_hash, ORIG);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_xmp_without_a_blake3_derived_from() {
|
||||
assert!(parse_version_xmp("<x:xmpmeta><dc:title>hi</dc:title></x:xmpmeta>").is_none());
|
||||
let other = "<xmpMM:DerivedFrom><stRef:documentID>xmp.did:1234</stRef:documentID></xmpMM:DerivedFrom>";
|
||||
assert!(parse_version_xmp(other).is_none());
|
||||
// An ingredient alone is not a link.
|
||||
let only_ing = format!(
|
||||
"<xmpMM:Ingredients><rdf:Bag><rdf:li><stRef:documentID>blake3:{REF}</stRef:documentID></rdf:li></rdf:Bag></xmpMM:Ingredients>"
|
||||
);
|
||||
assert!(parse_version_xmp(&only_ing).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_xmp_and_prompt_chunks_from_a_png_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("v.png");
|
||||
write_png(
|
||||
&p,
|
||||
Some(&producer_xmp(ORIG, "restored", &[])),
|
||||
Some(r#"{"1": {"class_type": "LoadImage"}}"#),
|
||||
1,
|
||||
);
|
||||
let got = read_version_xmp(&p).unwrap().unwrap();
|
||||
assert_eq!(got.original_hash, ORIG);
|
||||
let text = read_png_text(&p, &["prompt"]).unwrap();
|
||||
assert_eq!(text[0].1, r#"{"1": {"class_type": "LoadImage"}}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_png_and_non_png_have_no_link() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("plain.png");
|
||||
write_png(&p, None, None, 2);
|
||||
assert!(read_version_xmp(&p).unwrap().is_none());
|
||||
let j = dir.path().join("x.jpg");
|
||||
std::fs::write(&j, b"\xff\xd8\xff\xe0 not a png").unwrap();
|
||||
assert!(read_version_xmp(&j).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
+65
-4
@@ -28,7 +28,7 @@ use crate::content_hash;
|
||||
use crate::database::models::InsertImageExif;
|
||||
use crate::database::{
|
||||
ExifDao, PrecomputedReelDao, PreviewDao, SqliteExifDao, SqlitePrecomputedReelDao,
|
||||
SqlitePreviewDao, SqliteUserAiPrefsDao, UserAiPrefsDao,
|
||||
SqlitePreviewDao, SqliteUserAiPrefsDao, SqliteVersionDao, UserAiPrefsDao, VersionDao,
|
||||
};
|
||||
use crate::date_resolver;
|
||||
use crate::exif;
|
||||
@@ -42,6 +42,7 @@ use crate::perceptual_hash;
|
||||
use crate::tags;
|
||||
use crate::tags::SqliteTagDao;
|
||||
use crate::thumbnails;
|
||||
use crate::versions;
|
||||
use crate::video;
|
||||
use crate::video::actors::{
|
||||
GeneratePreviewClipMessage, QueueVideosMessage, VideoPlaylistManager, VideoToQueue,
|
||||
@@ -329,6 +330,9 @@ pub fn watch_files(
|
||||
let preview_dao = Arc::new(Mutex::new(
|
||||
Box::new(SqlitePreviewDao::new()) as Box<dyn PreviewDao>
|
||||
));
|
||||
let version_dao = Arc::new(Mutex::new(
|
||||
Box::new(SqliteVersionDao::new()) as Box<dyn VersionDao>
|
||||
));
|
||||
let face_dao = Arc::new(Mutex::new(
|
||||
Box::new(faces::SqliteFaceDao::new()) as Box<dyn faces::FaceDao>
|
||||
));
|
||||
@@ -561,6 +565,7 @@ pub fn watch_files(
|
||||
Arc::clone(&preview_dao),
|
||||
Arc::clone(&face_dao),
|
||||
Arc::clone(&watcher_tag_dao),
|
||||
Arc::clone(&version_dao),
|
||||
face_client.clone(),
|
||||
&effective_excludes,
|
||||
None,
|
||||
@@ -582,6 +587,7 @@ pub fn watch_files(
|
||||
Arc::clone(&preview_dao),
|
||||
Arc::clone(&face_dao),
|
||||
Arc::clone(&watcher_tag_dao),
|
||||
Arc::clone(&version_dao),
|
||||
face_client.clone(),
|
||||
&effective_excludes,
|
||||
Some(check_since),
|
||||
@@ -590,8 +596,26 @@ pub fn watch_files(
|
||||
);
|
||||
}
|
||||
|
||||
// Update media counts per library (metric aggregates across all)
|
||||
thumbnails::update_media_counts(Path::new(&lib.root_path), &effective_excludes);
|
||||
// Name-convention versions whose original is (now) known,
|
||||
// incl. the retry for originals that weren't hashed yet.
|
||||
versions::link_pending(lib, &exif_dao, &version_dao);
|
||||
|
||||
// Update media counts per library (metric aggregates across all).
|
||||
// A version is the same photo as its original, so it isn't counted.
|
||||
let hidden_versions: HashSet<String> = version_dao
|
||||
.lock()
|
||||
.expect("version_dao poisoned")
|
||||
.hidden_version_rows(&[])
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|(lib_id, _)| *lib_id == lib.id)
|
||||
.map(|(_, rel)| rel)
|
||||
.collect();
|
||||
thumbnails::update_media_counts_excluding(
|
||||
Path::new(&lib.root_path),
|
||||
&effective_excludes,
|
||||
&hidden_versions,
|
||||
);
|
||||
|
||||
// Missing-file detection: prune image_exif rows whose
|
||||
// source file is no longer on disk. Per-library, so we
|
||||
@@ -634,6 +658,21 @@ pub fn watch_files(
|
||||
}
|
||||
let _ = crate::database::reconcile::run(&mut conn);
|
||||
|
||||
// Photo versions: copy the original's date / GPS / camera
|
||||
// onto version rows whose copy is missing or stale (a
|
||||
// manual date override, a re-resolve). DB-only, idempotent.
|
||||
match version_dao
|
||||
.lock()
|
||||
.expect("version_dao poisoned")
|
||||
.sync_version_metadata()
|
||||
{
|
||||
Ok(n) if n > 0 => {
|
||||
info!("versions: refreshed date/GPS on {} version row(s)", n);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!("versions: metadata sync failed: {:?}", e),
|
||||
}
|
||||
|
||||
// Back-ref refresh: hash-keyed rows whose
|
||||
// (library_id, rel_path) tuple no longer matches any
|
||||
// image_exif row but whose hash still does. After a
|
||||
@@ -707,6 +746,7 @@ pub fn process_new_files(
|
||||
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
|
||||
face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>>,
|
||||
tag_dao: Arc<Mutex<Box<dyn tags::TagDao>>>,
|
||||
version_dao: Arc<Mutex<Box<dyn VersionDao>>>,
|
||||
face_client: crate::ai::face_client::FaceClient,
|
||||
excluded_dirs: &[String],
|
||||
modified_since: Option<SystemTime>,
|
||||
@@ -755,6 +795,10 @@ pub fn process_new_files(
|
||||
|
||||
let mut new_files_found = false;
|
||||
let mut files_needing_row = Vec::new();
|
||||
// Files whose image_exif row was written this tick (version linking
|
||||
// looks only at these; re-reading every PNG's XMP on each full scan
|
||||
// would be wasted I/O on a network share).
|
||||
let mut newly_registered: Vec<(PathBuf, String)> = Vec::new();
|
||||
|
||||
// Register every image/video file in image_exif. Rows without EXIF
|
||||
// still carry library_id, rel_path, content_hash, and size_bytes so
|
||||
@@ -796,6 +840,7 @@ pub fn process_new_files(
|
||||
);
|
||||
|
||||
for (file_path, relative_path) in files_needing_row {
|
||||
let registered_file = (file_path.clone(), relative_path.clone());
|
||||
let timestamp = Utc::now().timestamp();
|
||||
|
||||
// Hash + size from filesystem metadata — always attempted so
|
||||
@@ -899,10 +944,25 @@ pub fn process_new_files(
|
||||
);
|
||||
} else {
|
||||
debug!("Registered {} in image_exif", relative_path);
|
||||
newly_registered.push(registered_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Photo versions (#191) ──────────────────────────────────────────
|
||||
// Link before any derived work below, so a version is never face-scanned
|
||||
// or CLIP-encoded in the tick it appears. XMP first (PNG from the
|
||||
// ComfyUI node; survives renames and moves), then the file-name
|
||||
// convention. Both run after every new row exists, so a version walked
|
||||
// before its original in the same batch still finds it.
|
||||
if !newly_registered.is_empty() {
|
||||
let linked = versions::link_new_files(library, &newly_registered, &exif_dao, &version_dao)
|
||||
+ versions::sweep_name_links(library, &exif_dao, &version_dao);
|
||||
if linked > 0 {
|
||||
crate::duplicates::invalidate_caches();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Face detection pass ────────────────────────────────────────────
|
||||
// Run after EXIF writes so newly-registered files have their
|
||||
// content_hash populated. Skipped wholesale when face_client is
|
||||
@@ -920,7 +980,8 @@ pub fn process_new_files(
|
||||
// ensures small/medium deploys self-heal without operator
|
||||
// action.
|
||||
backfill::backfill_missing_content_hashes(&files, library, &exif_dao);
|
||||
let candidates = backfill::build_face_candidates(library, &files, &exif_dao, &face_dao);
|
||||
let candidates =
|
||||
backfill::build_face_candidates(library, &files, &exif_dao, &face_dao, &version_dao);
|
||||
debug!(
|
||||
"face_watch: scan tick — {} image file(s) walked, {} candidate(s) (library '{}', modified_since={})",
|
||||
files
|
||||
|
||||
Reference in new issue
Block a user