Photo versions: link AI restorations/edits to their original (#191) #193

Merged
cameron merged 6 commits from feat/191-photo-versions into master 2026-10-30 18:36:03 +00:00
35 changed files with 5554 additions and 44 deletions

No files matched your search

+83
View File
@@ -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) # Create a login user (no HTTP registration route; idempotent)
cargo run --bin create_user -- <username> <password> 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 ## 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 `derived.db` cache will need an invalidation hook for the changed
hash — design it alongside Branch D. 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 ### File Processing Pipeline
**Thumbnail Generation:** **Thumbnail Generation:**
@@ -554,6 +627,16 @@ POST /image/tags (add tag to file)
DELETE /image/tags (remove tag from file) DELETE /image/tags (remove tag from file)
POST /image/tags/batch (bulk tag updates) 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) // Memories (week-based grouping)
GET /memories?path=...&recursive=true GET /memories?path=...&recursive=true
+2 -1
View File
@@ -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 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 reset # back to the pristine baseline (after tests mutate things)
dev/e2e/e2e.sh stop 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 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 | | `private` | `EXCLUDED_DIRS` — never indexed |
| `Duplicates` | byte-identical copy of `2021/Paris Trip/IMG_1000.jpg` | | `Duplicates` | byte-identical copy of `2021/Paris Trip/IMG_1000.jpg` |
| `Empty Album` | folder with no media | | `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 "On This Day" is relative to the generation date — run
`e2e.sh fixtures --force && e2e.sh reset` on a later day to keep memories `e2e.sh fixtures --force && e2e.sh reset` on a later day to keep memories
+113
View File
@@ -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
View File
@@ -150,7 +150,7 @@ api() { # api METHOD PATH [JSON]; reuses $E2E_TOKEN when set (login is a bcrypt
} }
indexed_count() { 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 '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 ;; restart) cmd_stop; cmd_start ;;
status) cmd_status ;; status) cmd_status ;;
token) login ;; 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 "$@" ;; api) shift; api "$@" ;;
logs) tail -f "$LOG" ;; logs) tail -f "$LOG" ;;
info) cmd_info ;; info) cmd_info ;;
+198 -3
View File
@@ -24,8 +24,10 @@ import datetime as dt
import json import json
import os import os
import shutil import shutil
import struct
import subprocess import subprocess
import sys import sys
import zlib
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -115,6 +117,14 @@ class Item:
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
scenario: str = "" scenario: str = ""
copy_of: str | None = None # byte-identical duplicate of another item 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: def label_date(self) -> str:
if self.taken: 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", "Duplicates/IMG_1000 copy.jpg", "image", copy_of="2021/Paris Trip/IMG_1000.jpg",
scenario="exact duplicate 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 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) 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: def render(item: Item, idx: int, media: Path) -> None:
out = media / item.rel out = media / item.rel
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
@@ -355,6 +537,7 @@ def main() -> int:
if it.copy_of: if it.copy_of:
(media / it.rel).parent.mkdir(parents=True, exist_ok=True) (media / it.rel).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(media / it.copy_of, media / it.rel) shutil.copyfile(media / it.copy_of, media / it.rel)
link_versions(items, media)
# Pin mtimes last: exiftool rewrites (and so touches) the files. # Pin mtimes last: exiftool rewrites (and so touches) the files.
for it in items: for it in items:
when = it.mtime or it.taken or dt.datetime(2024, 1, 1) when = it.mtime or it.taken or dt.datetime(2024, 1, 1)
@@ -387,6 +570,15 @@ def main() -> int:
"excluded_dirs": ["/private"], "excluded_dirs": ["/private"],
"places": {k: {"lat": v[0], "lon": v[1]} for k, v in PLACES.items()}, "places": {k: {"lat": v[0], "lon": v[1]} for k, v in PLACES.items()},
"tags": tags, "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": [ "items": [
{ {
"path": it.rel, "kind": it.kind, "scenario": it.scenario, "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, "taken": it.taken.isoformat() if it.taken else None,
"gps": it.gps, "camera": it.camera[1] if it.camera else None, "gps": it.gps, "camera": it.camera[1] if it.camera else None,
"tags": it.tags, "duplicate_of": it.copy_of, "tags": it.tags, "duplicate_of": it.copy_of,
# A duplicate carries its source's bytes, EXIF included. "version_of": it.version_of,
"wall_clock": expected_wall_clock(by_path.get(it.copy_of, it)), # A duplicate carries its source's bytes, EXIF included; a
"true_instant": expected_true_instant(by_path.get(it.copy_of, it)), # 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 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
);
+20
View File
@@ -430,7 +430,17 @@ pub async fn generate_insight_handler(
&mut request.persona_id, &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 = 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 library = app_state.primary_library();
let gen_type = InsightGenerationType::Standard; let gen_type = InsightGenerationType::Standard;
@@ -748,7 +758,17 @@ pub async fn generate_agentic_insight_handler(
&mut request.persona_id, &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 = 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 library = app_state.primary_library();
let gen_type = InsightGenerationType::Agentic; let gen_type = InsightGenerationType::Agentic;
+1 -1
View File
@@ -2544,7 +2544,7 @@ Return ONLY the summary, nothing else."#,
); );
let rows = { let rows = {
let mut dao = self.exif_dao.lock().expect("Unable to lock ExifDao"); 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, Ok(r) => r,
Err(e) => return format!("Error querying photos: {:?}", e), Err(e) => return format!("Error querying photos: {:?}", e),
} }
+72 -4
View File
@@ -18,7 +18,7 @@ use prometheus::IntGauge;
use crate::camera_tz; use crate::camera_tz;
use crate::content_hash; use crate::content_hash;
use crate::database::models::PrecomputedReel; use crate::database::models::PrecomputedReel;
use crate::database::{ExifDao, PrecomputedReelDao, UtcBackfillCandidate}; use crate::database::{ExifDao, PrecomputedReelDao, UtcBackfillCandidate, VersionDao};
use crate::date_resolver; use crate::date_resolver;
use crate::face_watch; use crate::face_watch;
use crate::faces; 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 /// 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 /// yet. Re-querying image_exif here picks up rows the EXIF write loop
/// just inserted alongside any pre-existing rows the watcher walked /// 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( pub fn build_face_candidates(
library: &libraries::Library, library: &libraries::Library,
files: &[(PathBuf, String)], files: &[(PathBuf, String)],
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>, exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
face_dao: &Arc<Mutex<Box<dyn faces::FaceDao>>>, face_dao: &Arc<Mutex<Box<dyn faces::FaceDao>>>,
version_dao: &Arc<Mutex<Box<dyn VersionDao>>>,
) -> Vec<face_watch::FaceCandidate> { ) -> Vec<face_watch::FaceCandidate> {
// Restrict to image files; videos aren't face-scanned in v1 (kamadak // Restrict to image files; videos aren't face-scanned in v1 (kamadak
// doesn't even register them in image_exif). // 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 candidates = Vec::new();
let mut dao = face_dao.lock().expect("face dao"); let mut dao = face_dao.lock().expect("face dao");
for rel_path in image_paths { for rel_path in image_paths {
let Some(hash) = hash_by_path.get(&rel_path) else { let Some(hash) = hash_by_path.get(&rel_path) else {
continue; continue;
}; };
if versions.contains(hash) {
continue;
}
match dao.already_scanned(hash) { match dao.already_scanned(hash) {
Ok(true) => continue, Ok(true) => continue,
Ok(false) => candidates.push(face_watch::FaceCandidate { Ok(false) => candidates.push(face_watch::FaceCandidate {
@@ -891,9 +903,11 @@ mod tests {
use crate::database::models::{InsertImageExif, InsertLibrary}; use crate::database::models::{InsertImageExif, InsertLibrary};
use crate::database::test::in_memory_db_connection; 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::faces::{FaceDao, SqliteFaceDao};
use crate::libraries::Library; use crate::libraries::Library;
use diesel::sqlite::SqliteConnection;
/// Everything `setup` hands back to a test: tempdir, library, shared /// Everything `setup` hands back to a test: tempdir, library, shared
/// connection, and the two DAOs. Aliased to keep clippy's /// 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("unhashed.jpg"), "unhashed.jpg".to_string()),
(tmp.path().join("clip.mp4"), "clip.mp4".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!( assert_eq!(
candidates.len(), candidates.len(),
@@ -1159,6 +1175,58 @@ mod tests {
assert_eq!(candidates[0].content_hash, "hash-fresh"); 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) ------------- // --- True-instant backfill drain (ADR-0001, ticket #118) -------------
use crate::database::SqlitePrecomputedReelDao; use crate::database::SqlitePrecomputedReelDao;
+225
View File
@@ -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
View File
@@ -82,6 +82,10 @@ pub struct SearchHit {
/// Cosine similarity in [-1, 1]. In practice OpenAI CLIP returns /// Cosine similarity in [-1, 1]. In practice OpenAI CLIP returns
/// 0.10–0.40 for the typical photo library. /// 0.10–0.40 for the typical photo library.
pub score: f32, 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)] #[derive(Debug, Serialize)]
@@ -230,11 +234,13 @@ pub async fn score_photos(
/// to a row are skipped. Shared by both endpoints. /// to a row are skipped. Shared by both endpoints.
pub fn resolve_hits( pub fn resolve_hits(
exif_dao: &Mutex<Box<dyn ExifDao>>, exif_dao: &Mutex<Box<dyn ExifDao>>,
version_dao: &Mutex<Box<dyn crate::database::VersionDao>>,
scored: &[(f32, String)], scored: &[(f32, String)],
) -> Vec<SearchHit> { ) -> Vec<SearchHit> {
if scored.is_empty() { if scored.is_empty() {
return Vec::new(); return Vec::new();
} }
let summaries = crate::versions::load_summaries(version_dao);
let hashes: Vec<String> = scored.iter().map(|(_, h)| h.clone()).collect(); let hashes: Vec<String> = scored.iter().map(|(_, h)| h.clone()).collect();
let mut dao = exif_dao.lock().expect("exif dao"); let mut dao = exif_dao.lock().expect("exif dao");
let path_map = dao.get_rel_paths_for_hashes(&hashes).unwrap_or_else(|e| { let path_map = dao.get_rel_paths_for_hashes(&hashes).unwrap_or_else(|e| {
@@ -258,11 +264,15 @@ pub fn resolve_hits(
.get(hash) .get(hash)
.and_then(|paths| paths.first().cloned()) .and_then(|paths| paths.first().cloned())
.unwrap_or(row.file_path); .unwrap_or(row.file_path);
let versions = crate::versions::VersionFields::from_summary(
summaries.get(&(row.library_id, rel_path.clone())),
);
results.push(SearchHit { results.push(SearchHit {
library_id: row.library_id, library_id: row.library_id,
rel_path, rel_path,
content_hash: hash.clone(), content_hash: hash.clone(),
score: *score, score: *score,
versions,
}); });
} }
results results
@@ -343,7 +353,7 @@ pub async fn search_photos(
let end = (offset + limit).min(total_matching); let end = (offset + limit).min(total_matching);
scored.hits[offset..end].to_vec() 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 { Ok(HttpResponse::Ok().json(SearchResponse {
query: q_text, query: q_text,
+26
View File
@@ -108,6 +108,19 @@ pub struct PhotosResponse {
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub photo_libraries: Vec<i32>, 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) // Pagination metadata (only present when limit is set)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub total_count: Option<i64>, pub total_count: Option<i64>,
@@ -180,6 +193,13 @@ pub struct FilesRequest {
/// inside a group. /// inside a group.
#[serde(default)] #[serde(default)]
pub include_duplicates: Option<bool>, 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)] #[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
@@ -495,6 +515,11 @@ pub struct ExifSummary {
/// Capture wall clock to display (naive ISO-8601; see /// Capture wall clock to display (naive ISO-8601; see
/// `MetadataResponse::wall_clock`). /// `MetadataResponse::wall_clock`).
pub wall_clock: Option<String>, 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 { impl ExifSummary {
@@ -525,6 +550,7 @@ impl ExifSummary {
date_utc: r.date_utc, date_utc: r.date_utc,
camera_offset_minutes: r.camera_offset_minutes, camera_offset_minutes: r.camera_offset_minutes,
date_taken_tz_source: r.date_taken_tz_source, date_taken_tz_source: r.date_taken_tz_source,
versions: Default::default(),
} }
} }
} }
+125 -11
View File
@@ -8,6 +8,7 @@ use tracing_opentelemetry::OpenTelemetrySpanExt;
use crate::database::models::{ use crate::database::models::{
Favorite, ImageExif, InsertFavorite, InsertImageExif, InsertUser, User, Favorite, ImageExif, InsertFavorite, InsertImageExif, InsertUser, User,
}; };
use crate::database::version_dao::{NOT_A_VERSION_CLAUSE, visible_version_clause};
use crate::otel::trace_db_call; use crate::otel::trace_db_call;
/// Wire shape for a single member of a duplicate group, returned by /// Wire shape for a single member of a duplicate group, returned by
@@ -54,6 +55,7 @@ pub mod reconcile;
pub mod schema; pub mod schema;
pub mod search_dao; pub mod search_dao;
pub mod user_ai_prefs_dao; pub mod user_ai_prefs_dao;
pub mod version_dao;
pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao}; pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao};
pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; 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 preview_dao::{PreviewDao, SqlitePreviewDao};
pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao}; pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao};
pub use user_ai_prefs_dao::{SqliteUserAiPrefsDao, UserAiPrefsDao}; pub use user_ai_prefs_dao::{SqliteUserAiPrefsDao, UserAiPrefsDao};
pub use version_dao::{SqliteVersionDao, VersionDao};
pub trait UserDao { pub trait UserDao {
fn create_user(&mut self, user: &str, password: &str) -> Option<User>; 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 { impl FavoriteDao for SqliteFavoriteDao {
fn add_favorite(&mut self, user_id: i32, favorite_path: &str) -> Result<usize, DbError> { fn add_favorite(&mut self, user_id: i32, favorite_path: &str) -> Result<usize, DbError> {
use schema::favorites::dsl::*; use schema::favorites::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get FavoriteDao"); 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 if favorites
.filter(userid.eq(user_id).and(rel_path.eq(&favorite_path))) .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) { fn remove_favorite(&mut self, user_id: i32, favorite_path: String) {
use schema::favorites::dsl::*; 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) diesel::delete(favorites)
.filter(userid.eq(user_id).and(rel_path.eq(favorite_path))) .filter(userid.eq(user_id).and(rel_path.eq(favorite_path)))
.execute(self.connection.lock().unwrap().deref_mut()) .execute(connection.deref_mut())
.unwrap(); .unwrap();
} }
@@ -401,6 +422,10 @@ pub trait ExifDao: Sync + Send {
/// or the permanent floor) through `date_view::row_true_instant`'s /// or the permanent floor) through `date_view::row_true_instant`'s
/// per-source fallback. Every row returned satisfies /// per-source fallback. Every row returned satisfies
/// [`InstantRange::contains`](crate::date_view::InstantRange::contains). /// [`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( fn query_by_exif(
&mut self, &mut self,
library_id: Option<i32>, library_id: Option<i32>,
@@ -409,6 +434,7 @@ pub trait ExifDao: Sync + Send {
lens_model: Option<&str>, lens_model: Option<&str>,
gps_bounds: Option<(f64, f64, f64, f64)>, // (min_lat, max_lat, min_lon, max_lon) gps_bounds: Option<(f64, f64, f64, f64)>, // (min_lat, max_lat, min_lon, max_lon)
date_range: Option<crate::date_view::InstantRange>, date_range: Option<crate::date_view::InstantRange>,
include_versions: bool,
) -> Result<Vec<ImageExif>, DbError>; ) -> Result<Vec<ImageExif>, DbError>;
/// Get distinct camera makes with counts /// 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 /// Returns full rows so the caller can read the date triple
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`). /// (`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( fn get_memories_in_window(
&mut self, &mut self,
library_id: i32, library_id: i32,
@@ -660,6 +690,7 @@ pub trait ExifDao: Sync + Send {
conv_hi: i64, conv_hi: i64,
unconv_lo: i64, unconv_lo: i64,
unconv_hi: i64, unconv_hi: i64,
view_library_ids: &[i32],
) -> Result<Vec<ImageExif>, DbError>; ) -> Result<Vec<ImageExif>, DbError>;
/// Return image rows that have a `content_hash` but no `phash_64`, /// 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 /// `duplicate_of_hash IS NOT NULL` so the default photo listing hides
/// demoted siblings; the Apollo duplicates modal passes `true` to /// demoted siblings; the Apollo duplicates modal passes `true` to
/// see both survivors and demoted members inside a group. /// 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( fn list_rel_paths_for_libraries(
&mut self, &mut self,
library_ids: &[i32], library_ids: &[i32],
path_prefix: Option<&str>, path_prefix: Option<&str>,
include_duplicates: bool, include_duplicates: bool,
include_versions: bool,
) -> Result<Vec<(i32, String)>, DbError>; ) -> Result<Vec<(i32, String)>, DbError>;
/// Delete a single image_exif row scoped to `(library_id, rel_path)`. /// 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 { pub struct SqliteExifDao {
connection: Arc<Mutex<SqliteConnection>>, connection: Arc<Mutex<SqliteConnection>>,
} }
@@ -1101,6 +1143,7 @@ impl ExifDao for SqliteExifDao {
lens_model_filter: Option<&str>, lens_model_filter: Option<&str>,
gps_bounds: Option<(f64, f64, f64, f64)>, gps_bounds: Option<(f64, f64, f64, f64)>,
date_range: Option<crate::date_view::InstantRange>, date_range: Option<crate::date_view::InstantRange>,
include_versions: bool,
) -> Result<Vec<ImageExif>, DbError> { ) -> Result<Vec<ImageExif>, DbError> {
trace_db_call("query", "query_by_exif", |_span| { trace_db_call("query", "query_by_exif", |_span| {
use schema::image_exif::dsl::*; 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 connection = self.connection.lock().expect("Unable to get ExifDao");
let mut query = image_exif.into_boxed(); 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 scope (most-selective filter — apply first so the
// `(library_id, ...)` indexes are eligible). // `(library_id, ...)` indexes are eligible).
if let Some(lib_id) = library_id_filter { if let Some(lib_id) = library_id_filter {
@@ -1175,6 +1225,9 @@ impl ExifDao for SqliteExifDao {
image_exif image_exif
.filter(camera_make.is_not_null()) .filter(camera_make.is_not_null())
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
&visible_version_clause(&[]),
))
.group_by(camera_make) .group_by(camera_make)
.select((camera_make, count(id))) .select((camera_make, count(id)))
.order(count(id).desc()) .order(count(id).desc())
@@ -1200,6 +1253,9 @@ impl ExifDao for SqliteExifDao {
image_exif image_exif
.filter(camera_make.is_not_null()) .filter(camera_make.is_not_null())
.filter(camera_model.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)) .group_by((camera_make, camera_model))
.select((camera_make, camera_model, count(id))) .select((camera_make, camera_model, count(id)))
.order(count(id).desc()) .order(count(id).desc())
@@ -1260,6 +1316,9 @@ impl ExifDao for SqliteExifDao {
// Query all photos with non-null GPS coordinates // Query all photos with non-null GPS coordinates
let mut query = image_exif let mut query = image_exif
.filter(gps_latitude.is_not_null().and(gps_longitude.is_not_null())) .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(); .into_boxed();
// Apply path filtering // Apply path filtering
@@ -1570,6 +1629,10 @@ impl ExifDao for SqliteExifDao {
.filter(library_id.eq(library_id_val)) .filter(library_id.eq(library_id_val))
.filter(clip_embedding.is_null()) .filter(clip_embedding.is_null())
.filter(content_hash.is_not_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))) .filter(diesel::dsl::not(diesel::dsl::exists(failed)))
.select((rel_path, content_hash.assume_not_null())) .select((rel_path, content_hash.assume_not_null()))
.order(id.asc()) .order(id.asc())
@@ -1683,6 +1746,9 @@ impl ExifDao for SqliteExifDao {
let mut query = image_exif let mut query = image_exif
.filter(content_hash.is_not_null()) .filter(content_hash.is_not_null())
.filter(clip_embedding.is_not_null()) .filter(clip_embedding.is_not_null())
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
NOT_A_VERSION_CLAUSE,
))
.into_boxed(); .into_boxed();
if !library_ids_val.is_empty() { if !library_ids_val.is_empty() {
query = query.filter(library_id.eq_any(library_ids_val)); query = query.filter(library_id.eq_any(library_ids_val));
@@ -1877,6 +1943,7 @@ impl ExifDao for SqliteExifDao {
conv_hi: i64, conv_hi: i64,
unconv_lo: i64, unconv_lo: i64,
unconv_hi: i64, unconv_hi: i64,
view_library_ids: &[i32],
) -> Result<Vec<ImageExif>, DbError> { ) -> Result<Vec<ImageExif>, DbError> {
trace_db_call("query", "get_memories_in_window", |_span| { trace_db_call("query", "get_memories_in_window", |_span| {
use crate::database::schema::image_exif::dsl as d; use crate::database::schema::image_exif::dsl as d;
@@ -1885,6 +1952,9 @@ impl ExifDao for SqliteExifDao {
d::image_exif d::image_exif
.filter(d::library_id.eq(library_id)) .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.is_not_null())
.filter(d::date_taken.ge(unconv_lo)) .filter(d::date_taken.ge(unconv_lo))
.filter(d::date_taken.le(unconv_hi)) .filter(d::date_taken.le(unconv_hi))
@@ -1936,13 +2006,27 @@ impl ExifDao for SqliteExifDao {
.map_err(|e| anyhow::anyhow!("Query error: {}", e))? .map_err(|e| anyhow::anyhow!("Query error: {}", e))?
.flatten(); .flatten();
// A version shares its annotations (tags, favorites) with its
// original, so its hash set includes the original's.
let paths = match hash { let paths = match hash {
Some(h) => image_exif Some(h) => {
.filter(content_hash.eq(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) .select(rel_path)
.distinct() .distinct()
.load::<String>(connection.deref_mut()) .load::<String>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?, .map_err(|e| anyhow::anyhow!("Query error: {}", e))?
}
None => vec![rel_path_val.to_string()], None => vec![rel_path_val.to_string()],
}; };
@@ -2041,6 +2125,7 @@ impl ExifDao for SqliteExifDao {
library_ids: &[i32], library_ids: &[i32],
path_prefix: Option<&str>, path_prefix: Option<&str>,
include_duplicates: bool, include_duplicates: bool,
include_versions: bool,
) -> Result<Vec<(i32, String)>, DbError> { ) -> Result<Vec<(i32, String)>, DbError> {
trace_db_call("query", "list_rel_paths_for_libraries", |_span| { trace_db_call("query", "list_rel_paths_for_libraries", |_span| {
use schema::image_exif::dsl::*; 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 query
.load::<(i32, String)>(connection.deref_mut()) .load::<(i32, String)>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e)) .map_err(|e| anyhow::anyhow!("Query error: {}", e))
@@ -2261,6 +2352,9 @@ impl ExifDao for SqliteExifDao {
use schema::image_exif::dsl::*; use schema::image_exif::dsl::*;
let mut q = image_exif let mut q = image_exif
.filter(content_hash.eq_any(&dup_hashes)) .filter(content_hash.eq_any(&dup_hashes))
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
NOT_A_VERSION_CLAUSE,
))
.select(( .select((
library_id, library_id,
rel_path, rel_path,
@@ -2341,6 +2435,9 @@ impl ExifDao for SqliteExifDao {
let mut q = image_exif let mut q = image_exif
.filter(content_hash.is_not_null()) .filter(content_hash.is_not_null())
.filter(phash_64.is_not_null()) .filter(phash_64.is_not_null())
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
NOT_A_VERSION_CLAUSE,
))
.select(( .select((
library_id, library_id,
rel_path, rel_path,
@@ -2419,6 +2516,9 @@ impl ExifDao for SqliteExifDao {
let mut q = image_exif let mut q = image_exif
.filter(content_hash.is_not_null()) .filter(content_hash.is_not_null())
.filter(diesel::dsl::sql::<diesel::sql_types::Bool>(
NOT_A_VERSION_CLAUSE,
))
.select((library_id, rel_path)) .select((library_id, rel_path))
.into_boxed(); .into_boxed();
if let Some(lib) = library_id_filter { if let Some(lib) = library_id_filter {
@@ -2747,13 +2847,13 @@ mod exif_dao_tests {
// Union: both rows. // Union: both rows.
let all = dao let all = dao
.query_by_exif(None, None, None, None, None, None) .query_by_exif(None, None, None, None, None, None, false)
.unwrap(); .unwrap();
assert_eq!(all.len(), 2); assert_eq!(all.len(), 2);
// Scoped to lib 2: only archive row. // Scoped to lib 2: only archive row.
let lib2 = dao 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(); .unwrap();
assert_eq!(lib2.len(), 1); assert_eq!(lib2.len(), 1);
assert_eq!(lib2[0].file_path, "archive/a.jpg"); assert_eq!(lib2[0].file_path, "archive/a.jpg");
@@ -2906,6 +3006,7 @@ mod exif_dao_tests {
None, None,
None, None,
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)), jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
false,
) )
.unwrap(); .unwrap();
assert_eq!(paths(rows), vec!["redated.jpg"]); assert_eq!(paths(rows), vec!["redated.jpg"]);
@@ -2947,6 +3048,7 @@ mod exif_dao_tests {
None, None,
None, None,
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)), jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
false,
) )
.unwrap(); .unwrap();
assert_eq!(paths(rows), vec!["jul5_early.jpg"]); assert_eq!(paths(rows), vec!["jul5_early.jpg"]);
@@ -2977,6 +3079,7 @@ mod exif_dao_tests {
None, None,
None, None,
jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)), jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)),
false,
) )
.unwrap(); .unwrap();
assert_eq!(paths(in_day), vec!["tokyo.jpg"]); assert_eq!(paths(in_day), vec!["tokyo.jpg"]);
@@ -2990,6 +3093,7 @@ mod exif_dao_tests {
None, None,
None, None,
jul5_ny(Some(1_720_195_200), Some(JUL5_NY_END)), jul5_ny(Some(1_720_195_200), Some(JUL5_NY_END)),
false,
) )
.unwrap(); .unwrap();
assert!(afternoon.is_empty()); assert!(afternoon.is_empty());
@@ -3019,6 +3123,7 @@ mod exif_dao_tests {
None, None,
None, None,
jul5_ny(Some(JUL5_NY_START), None), jul5_ny(Some(JUL5_NY_START), None),
false,
) )
.unwrap(); .unwrap();
assert_eq!(paths(from_only), vec!["a.jpg"]); assert_eq!(paths(from_only), vec!["a.jpg"]);
@@ -3030,12 +3135,13 @@ mod exif_dao_tests {
None, None,
None, None,
jul5_ny(None, Some(JUL5_NY_START)), jul5_ny(None, Some(JUL5_NY_START)),
false,
) )
.unwrap(); .unwrap();
assert!(to_only.is_empty()); assert!(to_only.is_empty());
assert!(jul5_ny(None, None).is_none()); assert!(jul5_ny(None, None).is_none());
let all = dao let all = dao
.query_by_exif(None, None, None, None, None, None) .query_by_exif(None, None, None, None, None, None, false)
.unwrap(); .unwrap();
assert_eq!(paths(all), vec!["a.jpg", "undated.jpg"]); 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); insert_row_with_source(&mut dao, 1, "nodate.jpg", None, None);
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20); 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> = let paths: std::collections::HashSet<String> =
rows.into_iter().map(|r| r.file_path).collect(); rows.into_iter().map(|r| r.file_path).collect();
assert!( assert!(
@@ -3252,8 +3360,12 @@ mod exif_dao_tests {
Some("exif"), Some("exif"),
); );
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20); let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
let lib1 = dao.get_memories_in_window(1, clo, chi, ulo, uhi).unwrap(); let lib1 = dao
let lib2 = dao.get_memories_in_window(2, clo, chi, ulo, uhi).unwrap(); .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.len(), 1);
assert_eq!(lib1[0].file_path, "main/x.jpg"); assert_eq!(lib1[0].file_path, "main/x.jpg");
assert_eq!(lib2.len(), 1); assert_eq!(lib2.len(), 1);
@@ -3318,7 +3430,9 @@ mod exif_dao_tests {
let chi = now_ts - year; let chi = now_ts - year;
let ulo = now_ts - year - 86_400; let ulo = now_ts - year - 86_400;
let uhi = now_ts; 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> = let paths: std::collections::HashSet<String> =
rows.into_iter().map(|r| r.file_path).collect(); rows.into_iter().map(|r| r.file_path).collect();
assert!( assert!(
+30
View File
@@ -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! { diesel::table! {
precomputed_reels (id) { precomputed_reels (id) {
id -> Integer, id -> Integer,
@@ -392,6 +419,9 @@ diesel::allow_tables_to_appear_in_same_query!(
personas, personas,
persons, persons,
photo_insights, photo_insights,
photo_version_display,
photo_version_references,
photo_versions,
precomputed_reels, precomputed_reels,
search_history, search_history,
tagged_photo, tagged_photo,
File diff suppressed because it is too large. Load diff
+7
View File
@@ -65,6 +65,13 @@ lazy_static! {
static ref FOLDER_PAIR_CACHE: Mutex<Option<FolderPairCacheEntry>> = Mutex::new(None); 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` /// Drop the perceptual-cluster cache. Called from `resolve`/`unresolve`
/// so the next modal open reflects the soft-mark change immediately. /// so the next modal open reflects the soft-mark change immediately.
fn invalidate_perceptual_cache() { fn invalidate_perceptual_cache() {
+25 -3
View File
@@ -569,12 +569,16 @@ impl FaceDao for SqliteFaceDao {
// be filtered client-side and re-pulled every tick forever // be filtered client-side and re-pulled every tick forever
// because no marker row is written for excluded paths. // because no marker row is written for excluded paths.
let ext_predicate = image_path_predicate("rel_path"); 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!( let sql = format!(
"SELECT rel_path, content_hash \ "SELECT rel_path, content_hash \
FROM image_exif e \ FROM image_exif e \
WHERE library_id = ? \ WHERE library_id = ? \
AND content_hash IS NOT NULL \ AND content_hash IS NOT NULL \
AND {ext_predicate} \ AND {ext_predicate} \
AND {not_version} \
AND NOT EXISTS ( \ AND NOT EXISTS ( \
SELECT 1 FROM face_detections f \ SELECT 1 FROM face_detections f \
WHERE f.content_hash = e.content_hash \ 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 // hash can have many rows (multiple detected faces) but we
// want it counted once. // want it counted once.
let scanned: i64 = { 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 { if let Some(lib) = library_id {
q = q.filter(face_detections::library_id.eq(lib)); q = q.filter(face_detections::library_id.eq(lib));
} }
@@ -944,6 +952,9 @@ impl FaceDao for SqliteFaceDao {
let with_faces: i64 = { let with_faces: i64 = {
let mut q = face_detections::table let mut q = face_detections::table
.filter(face_detections::status.eq("detected")) .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(); .into_boxed();
if let Some(lib) = library_id { if let Some(lib) = library_id {
q = q.filter(face_detections::library_id.eq(lib)); q = q.filter(face_detections::library_id.eq(lib));
@@ -960,6 +971,9 @@ impl FaceDao for SqliteFaceDao {
let no_faces: i64 = { let no_faces: i64 = {
let mut q = face_detections::table let mut q = face_detections::table
.filter(face_detections::status.eq("no_faces")) .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(); .into_boxed();
if let Some(lib) = library_id { if let Some(lib) = library_id {
q = q.filter(face_detections::library_id.eq(lib)); q = q.filter(face_detections::library_id.eq(lib));
@@ -976,6 +990,9 @@ impl FaceDao for SqliteFaceDao {
let failed: i64 = { let failed: i64 = {
let mut q = face_detections::table let mut q = face_detections::table
.filter(face_detections::status.eq("failed")) .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(); .into_boxed();
if let Some(lib) = library_id { if let Some(lib) = library_id {
q = q.filter(face_detections::library_id.eq(lib)); 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. // below 100% for the duration of that backfill.
let total_photos: i64 = { let total_photos: i64 = {
let ext_predicate = image_path_predicate("rel_path"); 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 row: CountRow = if let Some(lib) = library_id {
let sql = format!( let sql = format!(
"SELECT COUNT(DISTINCT content_hash) AS count FROM image_exif \ "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) diesel::sql_query(sql)
.bind::<diesel::sql_types::Integer, _>(lib) .bind::<diesel::sql_types::Integer, _>(lib)
@@ -1018,7 +1039,8 @@ impl FaceDao for SqliteFaceDao {
} else { } else {
let sql = format!( let sql = format!(
"SELECT COUNT(DISTINCT content_hash) AS count FROM image_exif \ "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) diesel::sql_query(sql)
.get_result(conn.deref_mut()) .get_result(conn.deref_mut())
+102 -2
View File
@@ -342,6 +342,9 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
req.lens_model.as_deref(), req.lens_model.as_deref(),
gps_bounds, gps_bounds,
date_range, date_range,
// Hiding happens in the listing below; this is only the
// set the listing is intersected with.
true,
) )
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
warn!("EXIF query failed: {:?}", 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 search_recursively = req.recursive.unwrap_or(false);
let include_versions = req.include_versions.unwrap_or(false);
if let Some(tag_ids) = &req.tag_ids if let Some(tag_ids) = &req.tag_ids
&& search_recursively && search_recursively
{ {
@@ -498,6 +502,21 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
}) })
.collect::<Vec<(FileWithTagCount, i32)>>() .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| { .map(|paired| {
// Handle sorting - use helper function that supports EXIF date sorting and pagination // Handle sorting - use helper function that supports EXIF date sorting and pagination
let sort_type = req.sort.unwrap_or(NameAsc); 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_attribute("total_count", total_count.to_string());
span.set_status(Status::Ok); span.set_status(Status::Ok);
let (photo_display_paths, photo_version_counts) =
version_arrays(&app_state, &tagged_files, &photo_libraries);
HttpResponse::Ok().json(PhotosResponse { HttpResponse::Ok().json(PhotosResponse {
photos: tagged_files, photos: tagged_files,
dirs: vec![], dirs: vec![],
photo_libraries, photo_libraries,
photo_display_paths,
photo_version_counts,
total_count: pagination_metadata.0, total_count: pagination_metadata.0,
has_more: pagination_metadata.1, has_more: pagination_metadata.1,
next_offset: pagination_metadata.2, 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 include_duplicates = req.include_duplicates.unwrap_or(false);
let rows = { let rows = {
let mut dao = exif_dao.lock().expect("Unable to get ExifDao"); 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| { .unwrap_or_else(|e| {
warn!("list_rel_paths_for_libraries failed: {:?}", e); warn!("list_rel_paths_for_libraries failed: {:?}", e);
Vec::new() 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 { if !any_library_resolved {
error!("Bad photos request: {}", req.path); error!("Bad photos request: {}", req.path);
span.set_status(Status::error("Invalid 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_attribute("total_count", total_count.to_string());
span.set_status(Status::Ok); span.set_status(Status::Ok);
let (photo_display_paths, photo_version_counts) =
version_arrays(&app_state, &response_files, &response_libraries);
HttpResponse::Ok().json(PhotosResponse { HttpResponse::Ok().json(PhotosResponse {
photos: response_files, photos: response_files,
dirs, dirs,
photo_libraries: response_libraries, photo_libraries: response_libraries,
photo_display_paths,
photo_version_counts,
total_count: pagination_metadata.0, total_count: pagination_metadata.0,
has_more: pagination_metadata.1, has_more: pagination_metadata.1,
next_offset: pagination_metadata.2, 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( fn sort(
files: Vec<FileWithTagCount>, files: Vec<FileWithTagCount>,
file_libraries: Vec<i32>, file_libraries: Vec<i32>,
@@ -1253,13 +1343,20 @@ pub async fn list_exif_summary(
None, None,
None, None,
crate::date_view::InstantRange::new(req.date_from, req.date_to, display_zone, server_zone), crate::date_view::InstantRange::new(req.date_from, req.date_to, display_zone, server_zone),
false,
) { ) {
Ok(rows) => { Ok(rows) => {
let summaries = crate::versions::load_summaries(&app_state.version_dao);
let photos: Vec<ExifSummary> = rows let photos: Vec<ExifSummary> = rows
.into_iter() .into_iter()
.map(|r| { .map(|r| {
let name = library_names.get(&r.library_id).cloned(); 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(); .collect();
@@ -1622,6 +1719,7 @@ mod tests {
_: Option<&str>, _: Option<&str>,
_: Option<(f64, f64, f64, f64)>, _: Option<(f64, f64, f64, f64)>,
_: Option<crate::date_view::InstantRange>, _: Option<crate::date_view::InstantRange>,
_include_versions: bool,
) -> Result<Vec<crate::database::models::ImageExif>, DbError> { ) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
Ok(Vec::new()) Ok(Vec::new())
} }
@@ -1748,6 +1846,7 @@ mod tests {
_conv_hi: i64, _conv_hi: i64,
_unconv_lo: i64, _unconv_lo: i64,
_unconv_hi: i64, _unconv_hi: i64,
_view_library_ids: &[i32],
) -> Result<Vec<crate::database::models::ImageExif>, DbError> { ) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
Ok(Vec::new()) Ok(Vec::new())
} }
@@ -1794,6 +1893,7 @@ mod tests {
_library_ids: &[i32], _library_ids: &[i32],
_path_prefix: Option<&str>, _path_prefix: Option<&str>,
_include_duplicates: bool, _include_duplicates: bool,
_include_versions: bool,
) -> Result<Vec<(i32, String)>, DbError> { ) -> Result<Vec<(i32, String)>, DbError> {
Ok(vec![]) Ok(vec![])
} }
+2
View File
@@ -44,6 +44,8 @@ pub async fn favorites(
photos: favorites, photos: favorites,
dirs: Vec::new(), dirs: Vec::new(),
photo_libraries: Vec::new(), photo_libraries: Vec::new(),
photo_display_paths: Vec::new(),
photo_version_counts: Vec::new(),
total_count: None, total_count: None,
has_more: None, has_more: None,
next_offset: None, next_offset: None,
+1
View File
@@ -46,6 +46,7 @@ pub mod testhelpers;
pub mod thumbnails; pub mod thumbnails;
pub mod unified_search; pub mod unified_search;
pub mod utils; pub mod utils;
pub mod versions;
pub mod video; pub mod video;
pub mod watcher; pub mod watcher;
+78 -7
View File
@@ -282,6 +282,37 @@ pub fn refresh_back_refs(conn: &mut SqliteConnection) -> usize {
}); });
total += updated; 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 { if total > 0 {
info!("back-ref refresh: updated {} hash-keyed row(s)", total); info!("back-ref refresh: updated {} hash-keyed row(s)", total);
} }
@@ -299,6 +330,8 @@ pub struct GcStats {
pub deleted_face_detections: usize, pub deleted_face_detections: usize,
pub deleted_tagged_photo: usize, pub deleted_tagged_photo: usize,
pub deleted_photo_insights: 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 /// Hashes dropped from the pending set because they re-appeared
/// in image_exif (e.g. user remounted a backup that was briefly /// in image_exif (e.g. user remounted a backup that was briefly
/// missing). /// missing).
@@ -311,12 +344,16 @@ impl GcStats {
|| self.deleted_face_detections > 0 || self.deleted_face_detections > 0
|| self.deleted_tagged_photo > 0 || self.deleted_tagged_photo > 0
|| self.deleted_photo_insights > 0 || self.deleted_photo_insights > 0
|| self.deleted_photo_versions > 0
|| self.revived > 0 || self.revived > 0
} }
#[allow(dead_code)] #[allow(dead_code)]
pub fn total_deleted(&self) -> usize { 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() { if !to_delete.is_empty() {
match delete_hash_keyed_rows(conn, &to_delete) { match delete_hash_keyed_rows(conn, &to_delete) {
Ok((faces, tags, insights)) => { Ok((faces, tags, insights, versions)) => {
stats.deleted_face_detections = faces; stats.deleted_face_detections = faces;
stats.deleted_tagged_photo = tags; stats.deleted_tagged_photo = tags;
stats.deleted_photo_insights = insights; stats.deleted_photo_insights = insights;
stats.deleted_photo_versions = versions;
// Drop deleted hashes from pending so we don't try to // Drop deleted hashes from pending so we don't try to
// re-delete them next tick (they'll have already been // re-delete them next tick (they'll have already been
// removed from the orphan set). // removed from the orphan set).
@@ -452,12 +490,13 @@ pub fn run_orphan_gc(
if stats.changed() { if stats.changed() {
info!( 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.newly_marked,
stats.revived, stats.revived,
stats.deleted_face_detections, stats.deleted_face_detections,
stats.deleted_tagged_photo, stats.deleted_tagged_photo,
stats.deleted_photo_insights, stats.deleted_photo_insights,
stats.deleted_photo_versions,
state.pending.len(), state.pending.len(),
); );
} else { } 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 \ SELECT content_hash FROM tagged_photo WHERE content_hash IS NOT NULL \
UNION ALL \ UNION ALL \
SELECT content_hash FROM photo_insights WHERE content_hash IS NOT NULL \ 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 \ ) AS derived \
WHERE content_hash NOT IN ( \ WHERE content_hash NOT IN ( \
SELECT content_hash FROM image_exif WHERE content_hash IS NOT NULL \ 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`. /// 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( fn delete_hash_keyed_rows(
conn: &mut SqliteConnection, conn: &mut SqliteConnection,
hashes: &[String], hashes: &[String],
) -> QueryResult<(usize, usize, usize)> { ) -> QueryResult<(usize, usize, usize, usize)> {
if hashes.is_empty() { 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}; 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))) diesel::delete(photo_insights::table.filter(photo_insights::content_hash.eq_any(hashes)))
.execute(conn)?; .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)] #[cfg(test)]
+2
View File
@@ -326,6 +326,8 @@ fn main() -> std::io::Result<()> {
.service(handlers::favorites::favorites) .service(handlers::favorites::favorites)
.service(handlers::favorites::put_add_favorite) .service(handlers::favorites::put_add_favorite)
.service(handlers::favorites::delete_favorite) .service(handlers::favorites::delete_favorite)
.service(versions::get_versions)
.service(versions::put_display)
.service(handlers::image::get_file_metadata) .service(handlers::image::get_file_metadata)
.service(handlers::image::set_image_gps) .service(handlers::image::set_image_gps)
.service(handlers::image::set_image_date) .service(handlers::image::set_image_date)
+23 -1
View File
@@ -172,6 +172,10 @@ pub struct MemoryItem {
/// the camera's clock, which can differ from the owner's day that /// the camera's clock, which can differ from the owner's day that
/// `created` groups under. Render verbatim (ADR-0001). /// `created` groups under. Render verbatim (ADR-0001).
pub wall_clock: Option<String>, 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)] #[derive(Debug, Serialize)]
@@ -652,6 +656,11 @@ pub fn gather_memory_items(
// memory in the client's tz, used as the primary sort key. // memory in the client's tz, used as the primary sort key.
let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = Vec::new(); 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 { for lib in &libraries_to_scan {
let base = Path::new(&lib.root_path); let base = Path::new(&lib.root_path);
let effective = lib.effective_excluded_dirs(&app_state.excluded_dirs); 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() { let rows = match exif_dao.lock() {
Ok(mut dao) => { 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, Ok(rows) => rows,
Err(e) => { Err(e) => {
warn!( warn!(
@@ -697,6 +713,9 @@ pub fn gather_memory_items(
let created = crate::date_view::row_true_instant(&row, display_zone, server_zone); 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 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(( memories_with_dates.push((
MemoryItem { MemoryItem {
path: row.file_path, path: row.file_path,
@@ -707,6 +726,7 @@ pub fn gather_memory_items(
camera_offset_minutes: row.camera_offset_minutes, camera_offset_minutes: row.camera_offset_minutes,
date_taken_tz_source: row.date_taken_tz_source, date_taken_tz_source: row.date_taken_tz_source,
wall_clock, wall_clock,
versions,
}, },
display_day, display_day,
)); ));
@@ -1228,6 +1248,7 @@ mod tests {
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None, wall_clock: None,
versions: Default::default(),
} }
} }
@@ -1746,6 +1767,7 @@ mod tests {
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None, wall_clock: None,
versions: Default::default(),
}; };
let v: serde_json::Value = let v: serde_json::Value =
serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap(); serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap();
+4
View File
@@ -433,6 +433,7 @@ mod tests {
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None, wall_clock: None,
versions: Default::default(),
}, },
memories::MemoryItem { memories::MemoryItem {
path: "b".into(), path: "b".into(),
@@ -443,6 +444,7 @@ mod tests {
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None, wall_clock: None,
versions: Default::default(),
}, },
memories::MemoryItem { memories::MemoryItem {
path: "c".into(), path: "c".into(),
@@ -453,6 +455,7 @@ mod tests {
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None, wall_clock: None,
versions: Default::default(),
}, },
]; ];
assert_eq!( assert_eq!(
@@ -472,6 +475,7 @@ mod tests {
camera_offset_minutes: None, camera_offset_minutes: None,
date_taken_tz_source: None, date_taken_tz_source: None,
wall_clock: None, wall_clock: None,
versions: Default::default(),
} }
} }
fn item_at(ts: i64, name: &str) -> memories::MemoryItem { fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
+12 -1
View File
@@ -13,7 +13,7 @@ use crate::database::{
LocationHistoryDao, PrecomputedReelDao, SearchHistoryDao, SqliteCalendarEventDao, LocationHistoryDao, PrecomputedReelDao, SearchHistoryDao, SqliteCalendarEventDao,
SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao, SqliteInsightGenerationJobDao, SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao, SqliteInsightGenerationJobDao,
SqliteKnowledgeDao, SqliteLocationHistoryDao, SqlitePrecomputedReelDao, SqliteSearchHistoryDao, SqliteKnowledgeDao, SqliteLocationHistoryDao, SqlitePrecomputedReelDao, SqliteSearchHistoryDao,
SqliteUserAiPrefsDao, UserAiPrefsDao, connect, SqliteUserAiPrefsDao, SqliteVersionDao, UserAiPrefsDao, VersionDao, connect,
}; };
use crate::database::{PreviewDao, SqlitePreviewDao}; use crate::database::{PreviewDao, SqlitePreviewDao};
use crate::faces; use crate::faces;
@@ -98,6 +98,10 @@ pub struct AppState {
/// client; read by the nightly pre-generation scheduler. /// client; read by the nightly pre-generation scheduler.
#[allow(dead_code)] #[allow(dead_code)]
pub user_ai_prefs_dao: Arc<Mutex<Box<dyn UserAiPrefsDao>>>, 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 { impl AppState {
@@ -142,6 +146,7 @@ impl AppState {
insight_job_handles: Arc<Mutex<HashMap<i32, tokio::task::AbortHandle>>>, insight_job_handles: Arc<Mutex<HashMap<i32, tokio::task::AbortHandle>>>,
precomputed_reel_dao: Arc<Mutex<Box<dyn PrecomputedReelDao>>>, precomputed_reel_dao: Arc<Mutex<Box<dyn PrecomputedReelDao>>>,
user_ai_prefs_dao: Arc<Mutex<Box<dyn UserAiPrefsDao>>>, user_ai_prefs_dao: Arc<Mutex<Box<dyn UserAiPrefsDao>>>,
version_dao: Arc<Mutex<Box<dyn VersionDao>>>,
) -> Self { ) -> Self {
assert!( assert!(
!libraries_vec.is_empty(), !libraries_vec.is_empty(),
@@ -202,6 +207,7 @@ impl AppState {
insight_job_handles, insight_job_handles,
precomputed_reel_dao, precomputed_reel_dao,
user_ai_prefs_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>>> = let user_ai_prefs_dao: Arc<Mutex<Box<dyn UserAiPrefsDao>>> =
Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new()))); 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. // 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 base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env");
let mut seed_conn = connect(); let mut seed_conn = connect();
@@ -374,6 +383,7 @@ impl Default for AppState {
insight_job_handles, insight_job_handles,
precomputed_reel_dao, precomputed_reel_dao,
user_ai_prefs_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(HashMap::new())), // placeholder for test
Arc::new(Mutex::new(Box::new(SqlitePrecomputedReelDao::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(SqliteUserAiPrefsDao::new()))), // placeholder for test
Arc::new(Mutex::new(Box::new(SqliteVersionDao::new()))), // placeholder for test
) )
} }
} }
+17
View File
@@ -604,6 +604,16 @@ pub trait TagDao: Send + Sync {
fn delete_tag(&mut self, id: i32) -> anyhow::Result<bool>; 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 { pub struct SqliteTagDao {
connection: Arc<Mutex<SqliteConnection>>, connection: Arc<Mutex<SqliteConnection>>,
} }
@@ -670,6 +680,8 @@ impl TagDao for SqliteTagDao {
span.set_attribute("path", path.to_string()); span.set_attribute("path", path.to_string());
debug!("Getting Tags for path: {:?}", path); debug!("Getting Tags for path: {:?}", path);
// A photo version carries its original's tags.
let path = &canonical_path(conn.deref_mut(), path);
tags::table tags::table
.left_join(tagged_photo::table) .left_join(tagged_photo::table)
.filter(tagged_photo::rel_path.eq(&path)) .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("tag_name", tag_name.to_string());
span.set_attribute("path", path.to_string()); span.set_attribute("path", path.to_string());
let path = &canonical_path(conn.deref_mut(), path);
tags::table tags::table
.filter(tags::name.eq(tag_name)) .filter(tags::name.eq(tag_name))
.get_result::<Tag>(conn.deref_mut()) .get_result::<Tag>(conn.deref_mut())
@@ -893,6 +906,10 @@ impl TagDao for SqliteTagDao {
span.set_attribute("path", path.to_string()); span.set_attribute("path", path.to_string());
span.set_attribute("tag_id", tag_id.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, // Eagerly populate content_hash so this tag follows the bytes,
// not the path (see CLAUDE.md "Multi-library data model"). // not the path (see CLAUDE.md "Multi-library data model").
// None is fine — the reconciliation pass will backfill once // None is fine — the reconciliation pass will backfill once
+43 -2
View File
@@ -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]) { 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 image_count = 0;
let mut video_count = 0; let mut video_count = 0;
for entry in crate::file_scan::walk_library_files(media_dir, excluded_dirs) { 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) { if is_image(&entry) {
image_count += 1; image_count += 1;
} else if is_video(&entry) { } 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); (image_count, video_count)
VIDEO_GAUGE.set(video_count);
} }
pub fn is_image(entry: &DirEntry) -> bool { pub fn is_image(entry: &DirEntry) -> bool {
@@ -395,6 +421,21 @@ mod tests {
assert_eq!(s, PathBuf::from("/thumbs/notes.unsupported")); 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 /// Smoke-test update_media_counts: build a tempdir with two images
/// and one video, run the walker, and assert the gauges line up. /// and one video, run the walker, and assert the gauges line up.
/// Exercises the is_image / is_video classifier on real DirEntry /// Exercises the is_image / is_video classifier on real DirEntry
+18 -1
View File
@@ -459,12 +459,21 @@ pub async fn unified_search<TagD: TagDao>(
display_zone, display_zone,
server_zone, server_zone,
), ),
// Versions are dropped below, against the request's own
// library scope (this query is unscoped).
true,
) )
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
log::warn!("unified_search: query_by_exif failed: {e:?}"); log::warn!("unified_search: query_by_exif failed: {e:?}");
Vec::new() Vec::new()
}) })
}; };
let hidden_versions = state
.version_dao
.lock()
.expect("version dao")
.hidden_version_rows(&library_ids)
.unwrap_or_default();
candidate = rows candidate = rows
.into_iter() .into_iter()
@@ -473,6 +482,10 @@ pub async fn unified_search<TagD: TagDao>(
if !library_ids.is_empty() && !library_ids.contains(&row.library_id) { if !library_ids.is_empty() && !library_ids.contains(&row.library_id) {
return false; 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). // Precise GPS distance (the EXIF query only did a coarse box).
if let Some((lat, lon, radius_km)) = gps { if let Some((lat, lon, radius_km)) = gps {
match (row.gps_latitude, row.gps_longitude) { match (row.gps_latitude, row.gps_longitude) {
@@ -569,7 +582,7 @@ pub async fn unified_search<TagD: TagDao>(
); );
let total_matching = hits.len(); let total_matching = hits.len();
let page = paginate(&hits, offset, limit); 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 { HttpResponse::Ok().json(UnifiedResponse {
query: nl, query: nl,
interpreted: interpreted(&echo, resolved_place), interpreted: interpreted(&echo, resolved_place),
@@ -593,6 +606,7 @@ pub async fn unified_search<TagD: TagDao>(
let results: Vec<SearchHit> = if offset >= total_matching { let results: Vec<SearchHit> = if offset >= total_matching {
Vec::new() Vec::new()
} else { } else {
let summaries = crate::versions::load_summaries(&state.version_dao);
candidate[offset..end] candidate[offset..end]
.iter() .iter()
.map(|r| SearchHit { .map(|r| SearchHit {
@@ -600,6 +614,9 @@ pub async fn unified_search<TagD: TagDao>(
rel_path: r.file_path.clone(), rel_path: r.file_path.clone(),
content_hash: r.content_hash.clone().unwrap_or_default(), content_hash: r.content_hash.clone().unwrap_or_default(),
score: 0.0, score: 0.0,
versions: crate::versions::VersionFields::from_summary(
summaries.get(&(r.library_id, r.file_path.clone())),
),
}) })
.collect() .collect()
}; };
+979
View File
@@ -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}");
}
}
}
+269
View File
@@ -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>,
}
+940
View File
@@ -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);
}
+439
View File
@@ -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());
}
}
+312
View File
@@ -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(&region)?[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
View File
@@ -28,7 +28,7 @@ use crate::content_hash;
use crate::database::models::InsertImageExif; use crate::database::models::InsertImageExif;
use crate::database::{ use crate::database::{
ExifDao, PrecomputedReelDao, PreviewDao, SqliteExifDao, SqlitePrecomputedReelDao, ExifDao, PrecomputedReelDao, PreviewDao, SqliteExifDao, SqlitePrecomputedReelDao,
SqlitePreviewDao, SqliteUserAiPrefsDao, UserAiPrefsDao, SqlitePreviewDao, SqliteUserAiPrefsDao, SqliteVersionDao, UserAiPrefsDao, VersionDao,
}; };
use crate::date_resolver; use crate::date_resolver;
use crate::exif; use crate::exif;
@@ -42,6 +42,7 @@ use crate::perceptual_hash;
use crate::tags; use crate::tags;
use crate::tags::SqliteTagDao; use crate::tags::SqliteTagDao;
use crate::thumbnails; use crate::thumbnails;
use crate::versions;
use crate::video; use crate::video;
use crate::video::actors::{ use crate::video::actors::{
GeneratePreviewClipMessage, QueueVideosMessage, VideoPlaylistManager, VideoToQueue, GeneratePreviewClipMessage, QueueVideosMessage, VideoPlaylistManager, VideoToQueue,
@@ -329,6 +330,9 @@ pub fn watch_files(
let preview_dao = Arc::new(Mutex::new( let preview_dao = Arc::new(Mutex::new(
Box::new(SqlitePreviewDao::new()) as Box<dyn PreviewDao> 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( let face_dao = Arc::new(Mutex::new(
Box::new(faces::SqliteFaceDao::new()) as Box<dyn faces::FaceDao> Box::new(faces::SqliteFaceDao::new()) as Box<dyn faces::FaceDao>
)); ));
@@ -561,6 +565,7 @@ pub fn watch_files(
Arc::clone(&preview_dao), Arc::clone(&preview_dao),
Arc::clone(&face_dao), Arc::clone(&face_dao),
Arc::clone(&watcher_tag_dao), Arc::clone(&watcher_tag_dao),
Arc::clone(&version_dao),
face_client.clone(), face_client.clone(),
&effective_excludes, &effective_excludes,
None, None,
@@ -582,6 +587,7 @@ pub fn watch_files(
Arc::clone(&preview_dao), Arc::clone(&preview_dao),
Arc::clone(&face_dao), Arc::clone(&face_dao),
Arc::clone(&watcher_tag_dao), Arc::clone(&watcher_tag_dao),
Arc::clone(&version_dao),
face_client.clone(), face_client.clone(),
&effective_excludes, &effective_excludes,
Some(check_since), Some(check_since),
@@ -590,8 +596,26 @@ pub fn watch_files(
); );
} }
// Update media counts per library (metric aggregates across all) // Name-convention versions whose original is (now) known,
thumbnails::update_media_counts(Path::new(&lib.root_path), &effective_excludes); // 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 // Missing-file detection: prune image_exif rows whose
// source file is no longer on disk. Per-library, so we // 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); 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 // Back-ref refresh: hash-keyed rows whose
// (library_id, rel_path) tuple no longer matches any // (library_id, rel_path) tuple no longer matches any
// image_exif row but whose hash still does. After a // 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>>>, preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>>, face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>>,
tag_dao: Arc<Mutex<Box<dyn tags::TagDao>>>, tag_dao: Arc<Mutex<Box<dyn tags::TagDao>>>,
version_dao: Arc<Mutex<Box<dyn VersionDao>>>,
face_client: crate::ai::face_client::FaceClient, face_client: crate::ai::face_client::FaceClient,
excluded_dirs: &[String], excluded_dirs: &[String],
modified_since: Option<SystemTime>, modified_since: Option<SystemTime>,
@@ -755,6 +795,10 @@ pub fn process_new_files(
let mut new_files_found = false; let mut new_files_found = false;
let mut files_needing_row = Vec::new(); 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 // Register every image/video file in image_exif. Rows without EXIF
// still carry library_id, rel_path, content_hash, and size_bytes so // 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 { for (file_path, relative_path) in files_needing_row {
let registered_file = (file_path.clone(), relative_path.clone());
let timestamp = Utc::now().timestamp(); let timestamp = Utc::now().timestamp();
// Hash + size from filesystem metadata — always attempted so // Hash + size from filesystem metadata — always attempted so
@@ -899,10 +944,25 @@ pub fn process_new_files(
); );
} else { } else {
debug!("Registered {} in image_exif", relative_path); 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 ──────────────────────────────────────────── // ── Face detection pass ────────────────────────────────────────────
// Run after EXIF writes so newly-registered files have their // Run after EXIF writes so newly-registered files have their
// content_hash populated. Skipped wholesale when face_client is // 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 // ensures small/medium deploys self-heal without operator
// action. // action.
backfill::backfill_missing_content_hashes(&files, library, &exif_dao); 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!( debug!(
"face_watch: scan tick — {} image file(s) walked, {} candidate(s) (library '{}', modified_since={})", "face_watch: scan tick — {} image file(s) walked, {} candidate(s) (library '{}', modified_since={})",
files files