Compare commits
3
Commits
master
..
2d6663c12c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d6663c12c | ||
|
|
17d74ebafe | ||
|
|
ebed15bd59 |
No files matched your search
@@ -66,7 +66,7 @@ cargo fix
|
||||
```
|
||||
|
||||
### E2E dev server
|
||||
`dev/e2e/e2e.sh up` starts an isolated instance (port 8093, own build dir, db and
|
||||
`dev/e2e/e2e.sh up` starts an isolated instance (port 8090, own build dir, db and
|
||||
synthetic fixture library) alongside any real server, for app e2e / agent UI
|
||||
testing. `reset` restores the baseline. See `dev/e2e/README.md`.
|
||||
|
||||
@@ -360,13 +360,7 @@ hash — design it alongside Branch D.
|
||||
|
||||
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`. A
|
||||
**derived clip** (kind `animated`, `CLIP_KINDS`) is a video made from a photo
|
||||
(Enhance "Animate", `<name>.animated[.<n>].mp4`): linked, hidden and lifecycled
|
||||
exactly like a version, but never counted as one (`photo_version_counts`,
|
||||
`version_count`; they have their own `photo_clip_counts` / `clip_count`, so clients
|
||||
know to fetch `/image/versions` for a photo with only clips), never a display choice (`PUT /image/versions/display` answers 400
|
||||
`clip_not_displayable`), and listed apart, as `clips` in `GET /image/versions`. ImageApi
|
||||
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),
|
||||
@@ -382,14 +376,11 @@ for the `link_versions` binary).
|
||||
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) the file's XMP `xmpMM:DerivedFrom/stRef:documentID = blake3:<hex>`: a PNG's
|
||||
iTXt chunk `XML:com.adobe.xmp`, or an MP4 / MOV's top-level `uuid` box with the XMP
|
||||
UUID (both read by hand, no decode; `mdat` is seeked over); (2) the file-name convention next
|
||||
(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`, paged by a
|
||||
per-library resume cursor that wraps, so candidates that never resolve can't starve
|
||||
the ones behind them), so an original that wasn't hashed yet, or arrives later,
|
||||
still links. Only *new* rows
|
||||
`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
|
||||
@@ -406,23 +397,16 @@ for the `link_versions` binary).
|
||||
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. The non-recursive `/photos` `dirs` also drops a
|
||||
folder whose indexed contents are all hidden versions (typically `aiedits/`;
|
||||
`dirs_holding_only_hidden_versions`, one indexed range scan per dir).
|
||||
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). Annotations are keyed by rel_path alone, so a path that also names a
|
||||
non-version file in any library is never redirected. Linking merges favorites and
|
||||
tags already recorded on the version's own path into the original (union, duplicates
|
||||
dropped; `link_version`). Insight generation on a version path runs against the
|
||||
original. A *manual date* set or cleared on a version path (`POST /image/exif/date`,
|
||||
`/clear`) is applied to the original's row instead, as the copy above would overwrite
|
||||
it. Precomputed reels are not invalidated by a display change: reels render the
|
||||
original's path, never the display file.
|
||||
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.
|
||||
@@ -437,8 +421,7 @@ for the `link_versions` binary).
|
||||
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 version hash and the TSV's `original_hash` (stale rows are refused
|
||||
and listed); `unresolved` rows are listed, never guessed. `--apply`
|
||||
against the current hash; `unresolved` rows are listed, never guessed. `--apply`
|
||||
takes a `VACUUM INTO` snapshot first.
|
||||
|
||||
### Enhance on demand (#192)
|
||||
@@ -462,9 +445,7 @@ auth and stays LAN-only, so ImageApi relays previews too.
|
||||
`file`, `prompt_mode`, `prompt_join`, `output_megapixels`).
|
||||
- **Templates** are API-format workflow exports, read-only (never edit them from
|
||||
here). Nodes are found by `_meta.title`: `imageapi:source` (required, gets the
|
||||
photo path), `imageapi:save` (required, forced to `require_approval = true`; a
|
||||
`ImageApiSaveVideoVersion` there makes the template's `output` `video`, an MP4 clip,
|
||||
else `image`),
|
||||
photo path), `imageapi:save` (required, forced to `require_approval = true`),
|
||||
`imageapi:prompt` (optional; fills the first of `text` / `prompt` / `value`),
|
||||
`imageapi:reference` (optional). `prompt_mode = "append"`: the user's text is joined
|
||||
after the template's own prompt with `prompt_join` (default one space); empty text
|
||||
@@ -486,8 +467,7 @@ auth and stays LAN-only, so ImageApi relays previews too.
|
||||
`/history/{id}`: in queue -> queued/running with `queue_position`; finished ->
|
||||
`ready` (staged results in `pending`) or `failed` (ComfyUI's message); known to
|
||||
neither -> **`expired`** (ComfyUI restarted and cleared its temp folder). An
|
||||
unreachable ComfyUI for `unreachable_grace_secs` -> `unavailable`. `run_ms` is the `execution_start` -> end-event gap from `/history`, stored when the poller
|
||||
moves a job to `ready`/`failed` and never overwritten with null. A preview or
|
||||
unreachable ComfyUI for `unreachable_grace_secs` -> `unavailable`. A preview or
|
||||
approve that finds the staged result gone also expires the job (HTTP 410).
|
||||
- **Idle `/free`**: every 30 s, if the queue is empty, none of our jobs is active and
|
||||
ComfyUI's newest `/history` entry has not changed for `idle_free_secs` (it includes
|
||||
@@ -512,26 +492,16 @@ GET /enhance/templates
|
||||
"templates": [{"name","label","description","accepts_prompt",
|
||||
"prompt_mode": "append"|"replace", "prompt_required",
|
||||
"accepts_reference","default","output_megapixels",
|
||||
"warn_above_megapixels", "output": "image"|"video"}]}
|
||||
"warn_above_megapixels"}]}
|
||||
POST /enhance {"path","template","prompt"?,"reference_path"?,"library"?} -> job
|
||||
GET /enhance/jobs/{id} -> job
|
||||
GET /enhance/jobs?path=&library= -> {"jobs": [job, ...]} newest first, discarded hidden
|
||||
GET /enhance/jobs[?status=a,b] -> {"jobs": [...]} no path = the jobs manager: every photo's
|
||||
queued/running/unavailable jobs + ready/failed/expired ones touched in the last 3 days
|
||||
(OPEN_JOBS_WINDOW_SECS), max 100, newest first, discarded + approved hidden; DB only.
|
||||
`status` filters either form.
|
||||
GET /enhance/prompts?templates=a,b&limit=10 (templates optional = all; limit 1..50, default 10)
|
||||
-> {"prompts": [{"prompt","template","last_used","uses"}]} distinct trimmed prompts, newest
|
||||
use first; template/last_used from the newest job; discarded + failed jobs count;
|
||||
DB only (no library scope, answers even when ComfyUI is down or Enhance is off)
|
||||
GET /enhance/jobs/{id}/preview/{n} -> the staged PNG or MP4 (relayed from ComfyUI /view,
|
||||
with its content type)
|
||||
POST /enhance/jobs/{id}/approve {"n"} -> a /image/versions `versions[]` (or `clips[]`) entry
|
||||
GET /enhance/jobs/{id}/preview/{n} -> the staged PNG (relayed from ComfyUI /view)
|
||||
POST /enhance/jobs/{id}/approve {"n"} -> a /image/versions `versions[]` entry
|
||||
POST /enhance/jobs/{id}/discard -> job (hidden from the list; ComfyUI untouched)
|
||||
|
||||
job = {"id","status","queue_position"?,"error"?,"path","library_id","template",
|
||||
"prompt"?,"reference_path"?,"created_at","updated_at",
|
||||
"run_ms": <ms ComfyUI ran it, or null until finished / when ComfyUI gave no timings>,
|
||||
"pending": [{"n","preview": "/enhance/jobs/{id}/preview/{n}","approved"}],
|
||||
"saved": [{"n","path","library_id"}]}
|
||||
```
|
||||
@@ -741,24 +711,20 @@ GET /video/original/stream?token=... (Range-capable original bytes)
|
||||
|
||||
// Tags
|
||||
GET /image/tags/all (tags + counts, camera_makes, and cameras: make+model bodies)
|
||||
POST /image/tags (add tag to file; by name, case-insensitive, created when missing; already tagged = no-op)
|
||||
DELETE /image/tags (remove tag from file: every path sharing its content, matching reads)
|
||||
POST /image/tags/batch (replace ONE file's tag set: { file_name, tag_ids })
|
||||
POST /image/tags/bulk (many files, one transaction: { file_names, add: [names], remove: [ids] }
|
||||
-> { added, removed, tags }; 400 if a tag is in both)
|
||||
POST /image/tags (add tag to file)
|
||||
DELETE /image/tags (remove tag from file)
|
||||
POST /image/tags/batch (bulk tag updates)
|
||||
|
||||
// Photo versions (#191): AI restorations / upscales / edits shown as ONE photo
|
||||
GET /image/versions?path=...&library=... (path of the original or any version ->
|
||||
{ original:{path,library_id,hash}, versions:[{path,
|
||||
library_id,hash,kind,source,created_at,references,
|
||||
width,height}], clips:[same shape, kind animated],
|
||||
display_hash })
|
||||
width,height}], display_hash })
|
||||
POST /enhance ... (Enhance on demand; see "Enhance on demand")
|
||||
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 / photo_clip_counts; memories items,
|
||||
// search hits and /photos/exif rows carry display_path / version_count / clip_count. All skipped
|
||||
// when there are none.
|
||||
// parallel arrays photo_display_paths / photo_version_counts; memories items, search hits and
|
||||
// /photos/exif rows carry display_path / version_count. All skipped when there are none.
|
||||
|
||||
// Memories (week-based grouping)
|
||||
GET /memories?path=...&recursive=true
|
||||
@@ -813,15 +779,6 @@ GET /timezones (IANA zone list from chrono-tz; unauthentic
|
||||
**Service Builder Pattern:**
|
||||
Routes are registered via composable `ServiceBuilder` trait in `service.rs`. Allows modular feature addition.
|
||||
|
||||
**Error responses (`src/error.rs`, #202):**
|
||||
Every error body is `{"error": "<message safe to show a user>", "code": "<stable_snake_case>", "details"?: {...}}`.
|
||||
Build them with `ApiError` (`bad_request` / `not_found` / `conflict` / `unavailable` / `bad_gateway` / ... `(code, message)`),
|
||||
returned as `Err(..)` from an `ApiResult` handler or via `.into_response()`. Unexpected failures go through
|
||||
`ApiError::internal(cause)` (or `?` on anyhow / diesel): a 500 `internal` whose cause is logged, never sent, since it can
|
||||
hold SQL and library paths. Never rename a code a client may branch on; add a new one. Don't put file paths in `details`.
|
||||
Request-parsing failures, the auth extractor, multipart uploads and unknown routes use the format too (configs in
|
||||
`error.rs`, registered in `main.rs`). A test fails if a handler builds `HttpResponse::NotFound()` etc. by hand.
|
||||
|
||||
**Path Validation:**
|
||||
Always use `is_valid_full_path(&base_path, &requested_path, check_exists)` to prevent directory traversal attacks.
|
||||
|
||||
|
||||
+6
-9
@@ -10,17 +10,14 @@ dev/e2e/e2e.sh stop
|
||||
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:8093` (Android
|
||||
emulator) or `http://127.0.0.1:8093` (Expo web / `adb reverse tcp:8093 tcp:8093`),
|
||||
Log in from the app with **server** `http://10.0.2.2:8090` (Android
|
||||
emulator) or `http://127.0.0.1:8090` (Expo web / `adb reverse tcp:8090 tcp:8090`),
|
||||
**user** `e2e`, **password** `E2eTest#2026pass`.
|
||||
|
||||
Runs on native Linux (cargo, curl, ffmpeg with `drawtext`, exiftool) or in
|
||||
WSL driving the Windows toolchain; the script detects which. The port is 8093
|
||||
(8090, the old default, is taken by another long-running service on the dev
|
||||
box). If 8093 is taken too, pick another with `E2E_PORT=...`; the app's
|
||||
`drive-app` skill and Detox config read the same variable.
|
||||
The port is written into `.work/state/.env` by `reset`, so after changing it
|
||||
run `E2E_PORT=... dev/e2e/e2e.sh reset` once; `start` alone keeps the old one.
|
||||
WSL driving the Windows toolchain; the script detects which. If port 8090 is
|
||||
taken, pick another with `E2E_PORT=8091` (the app's `drive-app` skill reads
|
||||
the same variable).
|
||||
|
||||
## Isolation
|
||||
|
||||
@@ -29,7 +26,7 @@ It runs next to a real local server without touching it:
|
||||
| | real server | e2e server |
|
||||
|---|---|---|
|
||||
| exe | `target/debug/image-api.exe` | `dev/e2e/.work/bin/image-api-e2e.exe` (built in `target/e2e`) |
|
||||
| port | `BIND_URL` + `localhost:8088` | `127.0.0.1:8093` only (`LOCAL_BIND_URL=` disables the 8088 bind) |
|
||||
| port | `BIND_URL` + `localhost:8088` | `127.0.0.1:8090` only (`LOCAL_BIND_URL=` disables the 8088 bind) |
|
||||
| data | your `.env` | `dev/e2e/.work/state/` (db, thumbs, hls, gifs, previews, media) |
|
||||
| AI | configured | off — Ollama points at a closed port |
|
||||
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ fi
|
||||
EXE_NAME="image-api-e2e$EXT"
|
||||
LOG="$WORK/server.log"
|
||||
|
||||
E2E_PORT="${E2E_PORT:-8093}"
|
||||
E2E_PORT="${E2E_PORT:-8090}"
|
||||
# 127.0.0.1 is reachable from the Windows browser (Expo web), the Android
|
||||
# emulator (as 10.0.2.2) and USB devices via `adb reverse`. Set
|
||||
# E2E_BIND=0.0.0.0 for a physical device over Wi-Fi.
|
||||
@@ -258,7 +258,7 @@ Usage: dev/e2e/e2e.sh <command>
|
||||
logs tail -f the server log
|
||||
info connection details
|
||||
|
||||
Env: E2E_PORT (8093), E2E_BIND (127.0.0.1), E2E_USER, E2E_PASS.
|
||||
Env: E2E_PORT (8090), E2E_BIND (127.0.0.1), E2E_USER, E2E_PASS.
|
||||
Extra server env: dev/e2e/env.local (appended to the generated .env on reset).
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE enhance_jobs DROP COLUMN run_ms;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE enhance_jobs ADD COLUMN run_ms BIGINT;
|
||||
@@ -1,9 +1,8 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use opentelemetry::trace::Status;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::time::sleep;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
@@ -113,20 +112,14 @@ pub fn strip_summary_boilerplate(summary: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// "Today, <contact> and I discussed …" for any contact name (one or two
|
||||
// capitalised words), so no names are baked into the code.
|
||||
static NAMED_OPENING: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"^(?:[Tt]oday,?\s+)?\p{Lu}[\p{L}'’-]*(?:\s+\p{Lu}[\p{L}'’-]*)?\s+and\s+I\s+discussed",
|
||||
)
|
||||
.expect("valid regex")
|
||||
});
|
||||
if let Some(m) = NAMED_OPENING.find(&text) {
|
||||
text = text[m.end()..].trim_start().to_string();
|
||||
}
|
||||
|
||||
// Remove common opening phrases that add no semantic value
|
||||
let opening_phrases = [
|
||||
"Today, Melissa and I discussed",
|
||||
"Today, Amanda and I discussed",
|
||||
"Today Melissa and I discussed",
|
||||
"Today Amanda and I discussed",
|
||||
"Melissa and I discussed",
|
||||
"Amanda and I discussed",
|
||||
"Today, I discussed",
|
||||
"Today I discussed",
|
||||
"The conversation covered",
|
||||
@@ -383,24 +376,3 @@ async fn generate_and_store_daily_summary(
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strip_summary_boilerplate;
|
||||
|
||||
#[test]
|
||||
fn strips_a_named_opening_for_any_contact() {
|
||||
for raw in [
|
||||
"Today, Sam and I discussed the trip.",
|
||||
"Today Sam Lee and I discussed the trip.",
|
||||
"Jordan and I discussed the trip.",
|
||||
] {
|
||||
assert_eq!(strip_summary_boilerplate(raw), "the trip.", "{raw}");
|
||||
}
|
||||
assert_eq!(
|
||||
strip_summary_boilerplate("The weather and I discussed nothing"),
|
||||
"The weather and I discussed nothing",
|
||||
"a lowercase second word isn't a name"
|
||||
);
|
||||
}
|
||||
}
|
||||
+89
-77
@@ -1,4 +1,3 @@
|
||||
use crate::error::ApiError;
|
||||
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
||||
use futures::StreamExt;
|
||||
use opentelemetry::trace::Status;
|
||||
@@ -111,12 +110,15 @@ pub async fn generation_status_handler(
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
return ApiError::not_found("job_not_found", "That insight job no longer exists")
|
||||
.into_response();
|
||||
return HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": format!("Job {} not found", jid)
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to look up job {}: {:?}", jid, e);
|
||||
return ApiError::internal("Failed to look up job").into_response();
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to look up job"
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,12 +153,16 @@ pub async fn generation_status_handler(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to look up active job for {}: {:?}", normalized, e);
|
||||
return ApiError::internal("Failed to look up active job").into_response();
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to look up active job"
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ApiError::bad_request("invalid_request", "Give a job_id or a path").into_response()
|
||||
HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "Provide either job_id or path query parameter"
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -206,7 +212,9 @@ pub async fn cancel_generation_handler(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to cancel job {}: {:?}", jid, e);
|
||||
return ApiError::internal("Failed to cancel job").into_response();
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to cancel job"
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,7 +267,9 @@ pub async fn cancel_generation_handler(
|
||||
}));
|
||||
}
|
||||
|
||||
ApiError::bad_request("invalid_request", "Give a job_id or a file_path").into_response()
|
||||
HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "Provide either job_id or file_path in the request body"
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -481,7 +491,9 @@ pub async fn generate_insight_handler(
|
||||
Err(e) => {
|
||||
log::error!("Failed to create generation job: {:?}", e);
|
||||
span.set_status(Status::error("Failed to create generation job"));
|
||||
return ApiError::internal("Failed to create generation job").into_response();
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to create generation job"
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -628,10 +640,14 @@ pub async fn get_insight_handler(
|
||||
|
||||
match dao.get_insight_for_paths(&sibling_paths) {
|
||||
Ok(Some(insight)) => HttpResponse::Ok().json(PhotoInsightResponse::from(insight)),
|
||||
Ok(None) => insight_not_found().into_response(),
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "Insight not found"
|
||||
})),
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch insight ({}): {:?}", &query.path, e);
|
||||
ApiError::internal(format!("Failed to fetch insight: {:?}", e)).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to fetch insight: {:?}", e)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,7 +671,9 @@ pub async fn delete_insight_handler(
|
||||
})),
|
||||
Err(e) => {
|
||||
log::error!("Failed to delete insight: {:?}", e);
|
||||
ApiError::internal(format!("Failed to delete insight: {:?}", e)).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to delete insight: {:?}", e)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -681,7 +699,9 @@ pub async fn get_all_insights_handler(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch all insights: {:?}", e);
|
||||
ApiError::internal(format!("Failed to fetch insights: {:?}", e)).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to fetch insights: {:?}", e)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -711,7 +731,9 @@ pub async fn get_insight_history_handler(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch insight history ({}): {:?}", &query.path, e);
|
||||
ApiError::internal(format!("Failed to fetch insight history: {:?}", e)).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to fetch insight history: {:?}", e)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -800,7 +822,9 @@ pub async fn generate_agentic_insight_handler(
|
||||
Err(e) => {
|
||||
log::error!("Failed to create agentic generation job: {:?}", e);
|
||||
span.set_status(Status::error("Failed to create generation job"));
|
||||
return ApiError::internal("Failed to create generation job").into_response();
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to create generation job"
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1135,7 +1159,9 @@ pub async fn rate_insight_handler(
|
||||
})),
|
||||
Err(e) => {
|
||||
log::error!("Failed to rate insight: {:?}", e);
|
||||
ApiError::internal(format!("Failed to rate insight: {:?}", e)).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to rate insight: {:?}", e)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1187,7 +1213,9 @@ pub async fn export_training_data_handler(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to export training data: {:?}", e);
|
||||
ApiError::internal(format!("Failed to export training data: {:?}", e)).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to export training data: {:?}", e)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1280,7 +1308,9 @@ pub async fn chat_turn_handler(
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return libraries::unknown_library(e).into_response();
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1331,17 +1361,17 @@ pub async fn chat_turn_handler(
|
||||
|
||||
// Map well-known errors to client-facing 4xx codes.
|
||||
if msg.contains("no insight found") {
|
||||
insight_not_found().into_response()
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
||||
} else if msg.contains("no chat history") {
|
||||
no_chat_history().into_response()
|
||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
||||
} else if msg.contains("user_message")
|
||||
|| msg.contains("unknown backend")
|
||||
|| msg.contains("switching from local to hybrid")
|
||||
|| msg.contains("hybrid backend unavailable")
|
||||
{
|
||||
ApiError::bad_request("invalid_chat_request", msg).into_response()
|
||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
||||
} else {
|
||||
ApiError::internal(msg).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1426,7 +1456,9 @@ pub async fn chat_rewind_handler(
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return libraries::unknown_library(e).into_response();
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1444,18 +1476,13 @@ pub async fn chat_rewind_handler(
|
||||
let msg = format!("{}", e);
|
||||
log::error!("Chat rewind failed: {}", msg);
|
||||
if msg.contains("no insight found") {
|
||||
insight_not_found().into_response()
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
||||
} else if msg.contains("no chat history") {
|
||||
no_chat_history().into_response()
|
||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
||||
} else if msg.contains("cannot discard the initial") || msg.contains("out of range") {
|
||||
ApiError::bad_request(
|
||||
"stale_branch",
|
||||
"This conversation changed. Reload it and try again.",
|
||||
)
|
||||
.with_details(serde_json::json!({ "reason": msg }))
|
||||
.into_response()
|
||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
||||
} else {
|
||||
ApiError::internal(msg).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1526,11 +1553,11 @@ pub async fn chat_history_handler(
|
||||
Err(e) => {
|
||||
let msg = format!("{}", e);
|
||||
if msg.contains("no insight found") {
|
||||
insight_not_found().into_response()
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
||||
} else if msg.contains("no chat history") {
|
||||
no_chat_history().into_response()
|
||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
||||
} else {
|
||||
ApiError::internal(msg).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1579,18 +1606,14 @@ pub async fn chat_branches_handler(
|
||||
Err(e) => {
|
||||
let msg = format!("{}", e);
|
||||
if msg.contains("no insight found") {
|
||||
return insight_not_found().into_response();
|
||||
return HttpResponse::NotFound().json(serde_json::json!({ "error": msg }));
|
||||
} else if msg.contains("no chat history") {
|
||||
return no_chat_history().into_response();
|
||||
return HttpResponse::Conflict().json(serde_json::json!({ "error": msg }));
|
||||
} else if msg.contains("not found in tree") {
|
||||
return ApiError::bad_request(
|
||||
"stale_branch",
|
||||
"This conversation changed. Reload it and try again.",
|
||||
)
|
||||
.with_details(serde_json::json!({ "reason": msg }))
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }));
|
||||
} else {
|
||||
return ApiError::internal(msg).into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({ "error": msg }));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1615,7 +1638,9 @@ pub async fn chat_switch_branch_handler(
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return libraries::unknown_library(e).into_response();
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1629,18 +1654,13 @@ pub async fn chat_switch_branch_handler(
|
||||
let msg = format!("{}", e);
|
||||
log::error!("Switch branch failed: {}", msg);
|
||||
if msg.contains("no insight found") {
|
||||
insight_not_found().into_response()
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
||||
} else if msg.contains("no chat history") {
|
||||
no_chat_history().into_response()
|
||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
||||
} else if msg.contains("not found") || msg.contains("not a leaf") {
|
||||
ApiError::bad_request(
|
||||
"stale_branch",
|
||||
"This conversation changed. Reload it and try again.",
|
||||
)
|
||||
.with_details(serde_json::json!({ "reason": msg }))
|
||||
.into_response()
|
||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
||||
} else {
|
||||
ApiError::internal(msg).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1678,7 +1698,9 @@ pub async fn chat_stream_handler(
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return libraries::unknown_library(e).into_response();
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1831,7 +1853,9 @@ pub async fn turn_async_handler(
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return libraries::unknown_library(e).into_response();
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1912,7 +1936,9 @@ pub(crate) async fn turn_replay_impl(
|
||||
Some(e) => e,
|
||||
None => {
|
||||
span.set_status(Status::error("turn not found"));
|
||||
return turn_not_found().into_response();
|
||||
return HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": format!("turn {} not found", turn_id)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1927,11 +1953,9 @@ pub(crate) async fn turn_replay_impl(
|
||||
let (initial_frames, start_skip) = match entry.replay_from(skip_before).await {
|
||||
ReplayOutcome::Gone => {
|
||||
span.set_status(Status::error("buffer evicted"));
|
||||
return ApiError::gone(
|
||||
"turn_expired",
|
||||
"This reply is too old to resume. Reload the chat.",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::Gone().json(serde_json::json!({
|
||||
"error": "turn history has expired (buffer evicted)"
|
||||
}));
|
||||
}
|
||||
ReplayOutcome::CaughtUp { next_skip } => (Vec::new(), next_skip),
|
||||
ReplayOutcome::Events { events, next_skip } => {
|
||||
@@ -2076,7 +2100,9 @@ pub(crate) async fn cancel_turn_impl(
|
||||
Some(e) => e,
|
||||
None => {
|
||||
span.set_status(Status::error("turn not found"));
|
||||
return turn_not_found().into_response();
|
||||
return HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": format!("turn {} not found", turn_id)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2415,17 +2441,3 @@ mod owner_defaults_tests {
|
||||
assert_eq!(persona.as_deref(), Some("journal"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- error responses (#202) ---------------------------------------------
|
||||
|
||||
fn insight_not_found() -> ApiError {
|
||||
ApiError::not_found("insight_not_found", "There's no insight for this photo yet")
|
||||
}
|
||||
|
||||
fn no_chat_history() -> ApiError {
|
||||
ApiError::conflict("no_chat_history", "There's no chat for this insight yet")
|
||||
}
|
||||
|
||||
fn turn_not_found() -> ApiError {
|
||||
ApiError::not_found("turn_not_found", "That reply is no longer running")
|
||||
}
|
||||
+50
-60
@@ -22,7 +22,6 @@
|
||||
//! registry.
|
||||
//! - The HTTP handlers under ` /persona_chat/*`.
|
||||
|
||||
use crate::error::ApiError;
|
||||
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::Utc;
|
||||
@@ -86,39 +85,6 @@ impl std::fmt::Display for PersonaChatError {
|
||||
|
||||
impl std::error::Error for PersonaChatError {}
|
||||
|
||||
impl From<&PersonaChatError> for ApiError {
|
||||
fn from(e: &PersonaChatError) -> Self {
|
||||
match e {
|
||||
PersonaChatError::UnknownPersona(_) => {
|
||||
ApiError::not_found("persona_not_found", "That persona no longer exists")
|
||||
}
|
||||
PersonaChatError::UnknownConversation(_) => ApiError::not_found(
|
||||
"conversation_not_found",
|
||||
"That conversation no longer exists",
|
||||
),
|
||||
PersonaChatError::EmptyMessage => {
|
||||
ApiError::bad_request("empty_message", "Type a message first")
|
||||
}
|
||||
PersonaChatError::MessageTooLong => ApiError::bad_request(
|
||||
"message_too_long",
|
||||
"That message is too long (8192 characters at most)",
|
||||
),
|
||||
PersonaChatError::ConcurrentTurn => ApiError::conflict(
|
||||
"turn_in_progress",
|
||||
"A reply is still being written. Wait for it to finish.",
|
||||
),
|
||||
PersonaChatError::SmsThread(detail) => {
|
||||
log::warn!("persona chat: SMS thread unreadable: {detail}");
|
||||
ApiError::bad_gateway(
|
||||
"sms_thread_unavailable",
|
||||
"Couldn't read that message thread",
|
||||
)
|
||||
}
|
||||
PersonaChatError::Db(err) => ApiError::internal(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for PersonaChatError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
PersonaChatError::Db(e)
|
||||
@@ -1497,14 +1463,12 @@ pub async fn persona_chat_history_handler(
|
||||
) {
|
||||
Ok(view) => HttpResponse::Ok().json(view),
|
||||
Err(e) => {
|
||||
if format!("{e}").contains("not found") {
|
||||
ApiError::not_found(
|
||||
"conversation_not_found",
|
||||
"That conversation no longer exists",
|
||||
)
|
||||
.into_response()
|
||||
let msg = format!("{e}");
|
||||
if msg.contains("not found") {
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
||||
} else {
|
||||
ApiError::internal(e).into_response()
|
||||
log::error!("persona chat history load failed: {msg}");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1530,7 +1494,11 @@ pub async fn persona_chat_turn_handler(
|
||||
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => return crate::libraries::unknown_library(e).into_response(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {e}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
||||
@@ -1551,7 +1519,21 @@ pub async fn persona_chat_turn_handler(
|
||||
}
|
||||
Err(e) => {
|
||||
span.set_status(Status::error(format!("{e}")));
|
||||
ApiError::from(&e).into_response()
|
||||
match &e {
|
||||
PersonaChatError::UnknownPersona(_) | PersonaChatError::UnknownConversation(_) => {
|
||||
HttpResponse::NotFound()
|
||||
}
|
||||
PersonaChatError::EmptyMessage | PersonaChatError::MessageTooLong => {
|
||||
HttpResponse::BadRequest()
|
||||
}
|
||||
PersonaChatError::ConcurrentTurn => HttpResponse::Conflict(),
|
||||
PersonaChatError::SmsThread(_) => HttpResponse::BadGateway(),
|
||||
PersonaChatError::Db(_) => {
|
||||
log::error!("persona chat dispatch failed: {e}");
|
||||
HttpResponse::InternalServerError()
|
||||
}
|
||||
}
|
||||
.json(serde_json::json!({ "error": format!("{e}") }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1585,7 +1567,20 @@ pub async fn persona_chat_create_conversation_handler(
|
||||
Ok(conversation_id) => HttpResponse::Created().json(serde_json::json!({
|
||||
"conversation_id": conversation_id,
|
||||
})),
|
||||
Err(e) => ApiError::from(&e).into_response(),
|
||||
Err(e) => match &e {
|
||||
PersonaChatError::UnknownPersona(_) => {
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": format!("{e}") }))
|
||||
}
|
||||
PersonaChatError::SmsThread(_) => {
|
||||
log::warn!("anchored persona chat create failed: {e}");
|
||||
HttpResponse::BadGateway().json(serde_json::json!({ "error": format!("{e}") }))
|
||||
}
|
||||
_ => {
|
||||
log::error!("persona chat create failed: {e}");
|
||||
HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({ "error": format!("{e}") }))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1605,7 +1600,9 @@ pub async fn persona_chat_delete_conversation_handler(
|
||||
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "deleted": true })),
|
||||
Err(e) => {
|
||||
log::error!("persona chat delete failed: {e}");
|
||||
ApiError::internal(format!("{e}")).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("{e}")
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1629,7 +1626,9 @@ pub async fn persona_chat_conversations_handler(
|
||||
})),
|
||||
Err(e) => {
|
||||
log::error!("persona chat conversation list failed: {e}");
|
||||
ApiError::internal(format!("{e}")).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("{e}")
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1670,29 +1669,20 @@ pub struct PersonaChatBranchesQuery {
|
||||
/// equivalents return, so the client can share its error handling.
|
||||
fn branch_error_response(context: &str, e: &anyhow::Error) -> HttpResponse {
|
||||
let msg = format!("{e}");
|
||||
log::warn!("{context}: {msg}");
|
||||
log::error!("{context}: {msg}");
|
||||
if msg.contains("conversation not found") {
|
||||
ApiError::not_found(
|
||||
"conversation_not_found",
|
||||
"That conversation no longer exists",
|
||||
)
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
||||
} else if msg.contains("no chat history") {
|
||||
ApiError::conflict("no_chat_history", "There's no chat here yet")
|
||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
||||
} else if msg.contains("not found")
|
||||
|| msg.contains("not a leaf")
|
||||
|| msg.contains("out of range")
|
||||
|| msg.contains("cannot rewind past")
|
||||
{
|
||||
// The client's view of the branches is stale.
|
||||
ApiError::bad_request(
|
||||
"stale_branch",
|
||||
"This conversation changed. Reload it and try again.",
|
||||
)
|
||||
.with_details(serde_json::json!({ "reason": msg }))
|
||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
||||
} else {
|
||||
ApiError::internal(msg)
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// POST /persona_chat/rewind — move the tail of the conversation off the
|
||||
|
||||
+46
-64
@@ -5,7 +5,6 @@
|
||||
// voice from either an uploaded clip (device) or an existing library file
|
||||
// (audio read directly; video has its audio track extracted via ffmpeg).
|
||||
|
||||
use crate::error::ApiError;
|
||||
use actix_multipart::Multipart;
|
||||
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
||||
use anyhow::Context;
|
||||
@@ -387,11 +386,12 @@ pub async fn tts_speech_handler(
|
||||
let text = prepare_for_tts(&req.text);
|
||||
if text.is_empty() {
|
||||
span.set_status(Status::error("text is required"));
|
||||
return ApiError::bad_request("empty_text", "There's no text to read").into_response();
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "text is required" }));
|
||||
}
|
||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return tts_unavailable().into_response();
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured (set llamacpp.url in llm.toml)" }));
|
||||
};
|
||||
|
||||
let format = req
|
||||
@@ -419,11 +419,9 @@ pub async fn tts_speech_handler(
|
||||
// One synthesis at a time (see TTS_PERMIT) — fast-fail when busy.
|
||||
let Ok(_permit) = TTS_PERMIT.try_acquire() else {
|
||||
span.set_status(Status::error("tts busy"));
|
||||
return ApiError::too_many_requests(
|
||||
"tts_busy",
|
||||
"Already reading something. Try again shortly.",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::TooManyRequests().json(json!({
|
||||
"error": "TTS is busy with another request — try again shortly"
|
||||
}));
|
||||
};
|
||||
|
||||
// Wait for the LLM side to release the GPU before sending — the synthesis
|
||||
@@ -446,7 +444,7 @@ pub async fn tts_speech_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("tts synthesis failed"));
|
||||
log::error!("TTS synth failed: {:?}", e);
|
||||
tts_failed(&e).into_response()
|
||||
HttpResponse::BadGateway().json(json!({ "error": format!("TTS failed: {e}") }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,11 +516,12 @@ pub async fn create_speech_job_handler(
|
||||
let text = prepare_for_tts(&req.text);
|
||||
if text.is_empty() {
|
||||
span.set_status(Status::error("text is required"));
|
||||
return ApiError::bad_request("empty_text", "There's no text to read").into_response();
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "text is required" }));
|
||||
}
|
||||
if app_state.llamacpp.is_none() {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return tts_unavailable().into_response();
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured (set llamacpp.url in llm.toml)" }));
|
||||
}
|
||||
|
||||
let format = req
|
||||
@@ -661,7 +660,7 @@ pub async fn speech_job_status_handler(_claims: Claims, path: web::Path<String>)
|
||||
|
||||
let Ok(id) = Uuid::parse_str(&path.into_inner()) else {
|
||||
span.set_status(Status::error("invalid job id"));
|
||||
return ApiError::bad_request("invalid_job_id", "Invalid read-aloud job").into_response();
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
|
||||
};
|
||||
let resp = {
|
||||
let jobs = TTS_JOBS.lock().unwrap();
|
||||
@@ -680,7 +679,8 @@ pub async fn speech_job_status_handler(_claims: Claims, path: web::Path<String>)
|
||||
}
|
||||
None => {
|
||||
span.set_status(Status::error("job not found"));
|
||||
ApiError::not_found("job_not_found", "That read-aloud job has expired").into_response()
|
||||
HttpResponse::NotFound()
|
||||
.json(json!({ "error": "TTS job not found (it may have expired)" }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,7 +696,7 @@ pub async fn cancel_speech_job_handler(_claims: Claims, path: web::Path<String>)
|
||||
|
||||
let Ok(id) = Uuid::parse_str(&path.into_inner()) else {
|
||||
span.set_status(Status::error("invalid job id"));
|
||||
return ApiError::bad_request("invalid_job_id", "Invalid read-aloud job").into_response();
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
|
||||
};
|
||||
let status = with_job(id, |job| {
|
||||
if !job.status.is_terminal() {
|
||||
@@ -715,7 +715,8 @@ pub async fn cancel_speech_job_handler(_claims: Claims, path: web::Path<String>)
|
||||
}
|
||||
None => {
|
||||
span.set_status(Status::error("job not found"));
|
||||
ApiError::not_found("job_not_found", "That read-aloud job has expired").into_response()
|
||||
HttpResponse::NotFound()
|
||||
.json(json!({ "error": "TTS job not found (it may have expired)" }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -752,7 +753,8 @@ pub async fn list_voices_handler(
|
||||
|
||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return tts_unavailable().into_response();
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured" }));
|
||||
};
|
||||
match client.list_voices().await {
|
||||
Ok(v) => {
|
||||
@@ -764,7 +766,7 @@ pub async fn list_voices_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("list_voices failed"));
|
||||
log::error!("list_voices failed: {:?}", e);
|
||||
tts_failed(&e).into_response()
|
||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -781,7 +783,8 @@ pub async fn delete_voice_handler(
|
||||
|
||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return tts_unavailable().into_response();
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured" }));
|
||||
};
|
||||
// Same charset rule as creation — a name that sanitizes differently was
|
||||
// never a voice we created, and must not reach the upstream URL.
|
||||
@@ -790,7 +793,7 @@ pub async fn delete_voice_handler(
|
||||
Some(n) if n == raw => n,
|
||||
_ => {
|
||||
span.set_status(Status::error("invalid voice name"));
|
||||
return invalid_voice_name().into_response();
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid voice name" }));
|
||||
}
|
||||
};
|
||||
span.set_attribute("tts.voice_name", name.clone());
|
||||
@@ -804,7 +807,7 @@ pub async fn delete_voice_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("delete_voice failed"));
|
||||
log::error!("delete_voice failed: {:?}", e);
|
||||
tts_failed(&e).into_response()
|
||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -824,7 +827,8 @@ pub async fn create_voice_upload_handler(
|
||||
|
||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return tts_unavailable().into_response();
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured" }));
|
||||
};
|
||||
|
||||
let mut voice_name: Option<String> = None;
|
||||
@@ -849,12 +853,8 @@ pub async fn create_voice_upload_handler(
|
||||
while let Some(Ok(data)) = part.next().await {
|
||||
if file_bytes.len() + data.len() > MAX_VOICE_UPLOAD_BYTES {
|
||||
span.set_status(Status::error("voice clip exceeds limit"));
|
||||
return ApiError::new(
|
||||
actix_web::http::StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"body_too_large",
|
||||
"The voice clip is too big (25 MB at most)",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::PayloadTooLarge()
|
||||
.json(json!({ "error": "voice clip exceeds 25 MB" }));
|
||||
}
|
||||
file_bytes.put(data);
|
||||
}
|
||||
@@ -897,20 +897,21 @@ pub async fn create_voice_upload_handler(
|
||||
Ok(w) => w,
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error("invalid reference window"));
|
||||
return ApiError::bad_request("invalid_clip_window", msg).into_response();
|
||||
return HttpResponse::BadRequest().json(json!({ "error": msg }));
|
||||
}
|
||||
};
|
||||
|
||||
let Some(name) = voice_name.as_deref().and_then(sanitize_voice_name) else {
|
||||
span.set_status(Status::error("voice_name is required"));
|
||||
return invalid_voice_name().into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(json!({ "error": "voice_name is required (alphanumerics, - and _ only)" }));
|
||||
};
|
||||
// Tag the name with the ref-clip length (e.g. `grandma-30s`) so the
|
||||
// library shows which reference length produced each clone.
|
||||
let name = append_ref_window(&name, ref_start, ref_duration.round().max(1.0) as u32);
|
||||
if file_bytes.is_empty() {
|
||||
span.set_status(Status::error("voice_file is required"));
|
||||
return ApiError::bad_request("no_file", "Choose an audio file").into_response();
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "voice_file is required" }));
|
||||
}
|
||||
span.set_attribute("tts.voice_name", name.clone());
|
||||
span.set_attribute("tts.upload_bytes", file_bytes.len() as i64);
|
||||
@@ -924,7 +925,8 @@ pub async fn create_voice_upload_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("audio decode failed"));
|
||||
log::error!("voice upload transcode failed: {:?}", e);
|
||||
return undecodable_audio().into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(json!({ "error": "couldn't decode that audio file" }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -940,7 +942,7 @@ pub async fn create_voice_upload_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("create_voice failed"));
|
||||
log::error!("create_voice (upload) failed: {:?}", e);
|
||||
tts_failed(&e).into_response()
|
||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -976,18 +978,20 @@ pub async fn create_voice_from_library_handler(
|
||||
|
||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||
span.set_status(Status::error("tts backend not configured"));
|
||||
return tts_unavailable().into_response();
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.json(json!({ "error": "TTS backend not configured" }));
|
||||
};
|
||||
let Some(voice_name) = sanitize_voice_name(&req.voice_name) else {
|
||||
span.set_status(Status::error("voice_name is required"));
|
||||
return invalid_voice_name().into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(json!({ "error": "voice_name is required (alphanumerics, - and _ only)" }));
|
||||
};
|
||||
let (ref_start, ref_duration) =
|
||||
match resolve_ref_window(req.start_seconds, req.duration_seconds) {
|
||||
Ok(w) => w,
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error("invalid reference window"));
|
||||
return ApiError::bad_request("invalid_clip_window", msg).into_response();
|
||||
return HttpResponse::BadRequest().json(json!({ "error": msg }));
|
||||
}
|
||||
};
|
||||
// Tag the name with the ref-clip length (e.g. `grandma-30s`) so the
|
||||
@@ -1000,7 +1004,7 @@ pub async fn create_voice_from_library_handler(
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error("invalid library"));
|
||||
return libraries::unknown_library(msg).into_response();
|
||||
return HttpResponse::BadRequest().json(json!({ "error": msg }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1009,8 +1013,7 @@ pub async fn create_voice_from_library_handler(
|
||||
Some(p) if p.exists() => p,
|
||||
_ => {
|
||||
span.set_status(Status::error("file not found"));
|
||||
return ApiError::not_found("file_not_found", "That file doesn't exist")
|
||||
.into_response();
|
||||
return HttpResponse::NotFound().json(json!({ "error": "file not found in library" }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1018,7 +1021,8 @@ pub async fn create_voice_from_library_handler(
|
||||
// slurp arbitrary library files into memory / ffmpeg.
|
||||
if !is_audio_file(&abs) && !is_video_file(&abs) {
|
||||
span.set_status(Status::error("not an audio/video file"));
|
||||
return ApiError::bad_request("not_audio", "That file has no audio").into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(json!({ "error": "file is not an audio or video file" }));
|
||||
}
|
||||
span.set_attribute("tts.voice_name", voice_name.clone());
|
||||
|
||||
@@ -1027,7 +1031,8 @@ pub async fn create_voice_from_library_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("audio decode failed"));
|
||||
log::error!("voice reference prep failed for {:?}: {:?}", abs, e);
|
||||
return undecodable_audio().into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(json!({ "error": "couldn't decode that file's audio" }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1043,7 +1048,7 @@ pub async fn create_voice_from_library_handler(
|
||||
Err(e) => {
|
||||
span.set_status(Status::error("create_voice failed"));
|
||||
log::error!("create_voice (from-library) failed: {:?}", e);
|
||||
tts_failed(&e).into_response()
|
||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1055,29 +1060,6 @@ async fn prepare_reference_audio(abs: &Path, start: f64, duration: f64) -> anyho
|
||||
run_ffmpeg_to_wav(&abs.to_string_lossy(), start, duration).await
|
||||
}
|
||||
|
||||
// ---- error responses (#202) ---------------------------------------------
|
||||
|
||||
fn tts_unavailable() -> ApiError {
|
||||
ApiError::unavailable("tts_unavailable", "Read-aloud isn't set up on this server")
|
||||
}
|
||||
|
||||
/// The voice backend failed; its message is kept in `details` for debugging.
|
||||
fn tts_failed(e: &impl std::fmt::Display) -> ApiError {
|
||||
ApiError::bad_gateway("tts_failed", "The voice service failed. Try again.")
|
||||
.with_details(json!({ "reason": e.to_string() }))
|
||||
}
|
||||
|
||||
fn invalid_voice_name() -> ApiError {
|
||||
ApiError::bad_request(
|
||||
"invalid_voice_name",
|
||||
"Voice names can only use letters, numbers, - and _",
|
||||
)
|
||||
}
|
||||
|
||||
fn undecodable_audio() -> ApiError {
|
||||
ApiError::bad_request("undecodable_audio", "Couldn't read the audio in that file")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+7
-11
@@ -1,4 +1,3 @@
|
||||
use crate::error::ApiError;
|
||||
use actix_web::Responder;
|
||||
use actix_web::{
|
||||
HttpResponse,
|
||||
@@ -41,24 +40,20 @@ async fn register<D: UserDao>(
|
||||
) -> impl Responder {
|
||||
// Validate password strength
|
||||
if let Err(msg) = validate_password(&user.password) {
|
||||
return ApiError::bad_request("weak_password", msg).into_response();
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
|
||||
if !user.username.is_empty() && user.password == user.confirmation {
|
||||
let mut dao = user_dao.lock().expect("Unable to get UserDao");
|
||||
if dao.user_exists(&user.username) {
|
||||
ApiError::conflict("username_taken", "That username is taken").into_response()
|
||||
HttpResponse::BadRequest().finish()
|
||||
} else if let Some(_user) = dao.create_user(&user.username, &user.password) {
|
||||
HttpResponse::Ok().finish()
|
||||
} else {
|
||||
ApiError::internal("create_user failed").into_response()
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
} else {
|
||||
ApiError::bad_request(
|
||||
"invalid_registration",
|
||||
"Enter a username and the same password twice",
|
||||
)
|
||||
.into_response()
|
||||
HttpResponse::BadRequest().finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,14 +77,15 @@ pub async fn login<D: UserDao>(
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return ApiError::internal(format!("Failed to encode JWT: {}", e)).into_response();
|
||||
error!("Failed to encode JWT: {}", e);
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
|
||||
HttpResponse::Ok().json(Token { token: &token })
|
||||
} else {
|
||||
error!("Failed login attempt for user: '{}'", creds.username);
|
||||
ApiError::unauthorized("invalid_credentials", "Wrong username or password").into_response()
|
||||
HttpResponse::Unauthorized().finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,9 +179,6 @@ fn main() -> anyhow::Result<()> {
|
||||
Arc::new(Mutex::new(Box::new(SqliteVersionDao::new())));
|
||||
let mut guard = dao.lock().unwrap();
|
||||
let report = backfill::apply(guard.as_mut(), &proposals, &reviewed, &rows);
|
||||
for why in &report.stale_details {
|
||||
eprintln!(" stale reviewed row refused: {why}");
|
||||
}
|
||||
let synced = guard
|
||||
.sync_version_metadata()
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
||||
|
||||
+23
-29
@@ -24,7 +24,6 @@
|
||||
use crate::AppState;
|
||||
use crate::ai::clip_client::ClipError;
|
||||
use crate::database::ExifDao;
|
||||
use crate::error::ApiError;
|
||||
use actix_web::{HttpResponse, Result as ActixResult, web};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -105,6 +104,11 @@ pub struct SearchResponse {
|
||||
pub results: Vec<SearchHit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SearchError {
|
||||
error: String,
|
||||
}
|
||||
|
||||
/// Decode a stored `clip_embedding` BLOB back into a `Vec<f32>`. Returns
|
||||
/// `None` on malformed bytes — those rows get skipped rather than
|
||||
/// failing the whole query.
|
||||
@@ -312,7 +316,9 @@ pub async fn search_photos(
|
||||
) -> ActixResult<HttpResponse> {
|
||||
let q_text = query.q.trim().to_string();
|
||||
if q_text.is_empty() {
|
||||
return Ok(empty_query().into_response());
|
||||
return Ok(HttpResponse::BadRequest().json(SearchError {
|
||||
error: "query parameter `q` is required".into(),
|
||||
}));
|
||||
}
|
||||
|
||||
let limit = query.limit.clamp(1, 200);
|
||||
@@ -321,7 +327,7 @@ pub async fn search_photos(
|
||||
|
||||
let library_ids = match parse_library_scope(query.library_ids.as_deref(), query.library) {
|
||||
Ok(ids) => ids,
|
||||
Err(msg) => return Ok(crate::libraries::unknown_library(msg).into_response()),
|
||||
Err(msg) => return Ok(HttpResponse::BadRequest().json(SearchError { error: msg })),
|
||||
};
|
||||
|
||||
let scored = match score_photos(
|
||||
@@ -364,32 +370,20 @@ pub async fn search_photos(
|
||||
/// returned for each failure mode. Reused by the unified endpoint.
|
||||
pub fn score_error_response(e: ScoreError) -> HttpResponse {
|
||||
match e {
|
||||
ScoreError::Disabled => ApiError::unavailable(
|
||||
"search_unavailable",
|
||||
"Photo search isn't set up on this server",
|
||||
),
|
||||
ScoreError::Rejected(msg) => ApiError::bad_request(
|
||||
"query_rejected",
|
||||
"The search service couldn't use that query",
|
||||
)
|
||||
.with_details(serde_json::json!({ "reason": msg })),
|
||||
ScoreError::Unavailable(msg) => {
|
||||
log::warn!("CLIP service unavailable: {msg}");
|
||||
ApiError::bad_gateway(
|
||||
"search_unavailable",
|
||||
"Photo search is down right now. Try again shortly.",
|
||||
)
|
||||
ScoreError::Disabled => HttpResponse::ServiceUnavailable().json(SearchError {
|
||||
error: "CLIP search is disabled (no Apollo CLIP endpoint configured)".into(),
|
||||
}),
|
||||
ScoreError::Rejected(msg) => HttpResponse::BadRequest().json(SearchError {
|
||||
error: format!("query rejected: {msg}"),
|
||||
}),
|
||||
ScoreError::Unavailable(msg) => HttpResponse::BadGateway().json(SearchError {
|
||||
error: format!("CLIP service unavailable: {msg}"),
|
||||
}),
|
||||
ScoreError::MalformedEmbedding => HttpResponse::BadGateway().json(SearchError {
|
||||
error: "CLIP service returned a malformed query embedding".into(),
|
||||
}),
|
||||
ScoreError::Internal(msg) => {
|
||||
HttpResponse::InternalServerError().json(SearchError { error: msg })
|
||||
}
|
||||
ScoreError::MalformedEmbedding => ApiError::bad_gateway(
|
||||
"search_bad_response",
|
||||
"The search service sent an answer this server couldn't read",
|
||||
),
|
||||
ScoreError::Internal(msg) => ApiError::internal(msg),
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// `q` is missing or blank.
|
||||
pub fn empty_query() -> ApiError {
|
||||
ApiError::bad_request("empty_query", "Type something to search for")
|
||||
}
|
||||
+2
-11
@@ -1,12 +1,12 @@
|
||||
use std::{fs, str::FromStr};
|
||||
|
||||
use crate::database::models::ImageExif;
|
||||
use crate::error::ApiError;
|
||||
use anyhow::{Context, anyhow};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::error;
|
||||
|
||||
use actix_web::error::ErrorUnauthorized;
|
||||
use actix_web::{Error, FromRequest, HttpRequest, dev, http::header};
|
||||
use futures::future::{Ready, err, ok};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
|
||||
@@ -90,11 +90,7 @@ impl FromRequest for Claims {
|
||||
.map_or_else(
|
||||
|e| {
|
||||
error!("{}", e);
|
||||
err(ApiError::unauthorized(
|
||||
"unauthorized",
|
||||
"Your session has expired. Sign in again.",
|
||||
)
|
||||
.into())
|
||||
err(ErrorUnauthorized("Bad token"))
|
||||
},
|
||||
ok,
|
||||
)
|
||||
@@ -125,11 +121,6 @@ pub struct PhotosResponse {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub photo_version_counts: Vec<u32>,
|
||||
|
||||
/// How many derived clips (videos made from the photo, e.g. Animate)
|
||||
/// stand behind `photos[i]`; skipped when the page has none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub photo_clip_counts: Vec<u32>,
|
||||
|
||||
// Pagination metadata (only present when limit is set)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_count: Option<i64>,
|
||||
|
||||
+3
-179
@@ -59,29 +59,11 @@ pub struct EnhanceJobRow {
|
||||
pub created_at: i64,
|
||||
#[diesel(sql_type = BigInt)]
|
||||
pub updated_at: i64,
|
||||
/// Milliseconds ComfyUI actually ran the job; set once it finishes.
|
||||
#[diesel(sql_type = Nullable<BigInt>)]
|
||||
pub run_ms: Option<i64>,
|
||||
}
|
||||
|
||||
const COLUMNS: &str = "id, original_hash, library_id, rel_path, template, prompt, reference_hash, \
|
||||
reference_path, comfy_prompt_id, status, error, queue_position, pending, saved, \
|
||||
discarded, created_at, updated_at, run_ms";
|
||||
|
||||
/// One distinct prompt used across jobs, newest use first.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, QueryableByName)]
|
||||
pub struct RecentPrompt {
|
||||
#[diesel(sql_type = Text)]
|
||||
pub prompt: String,
|
||||
/// Template of the newest job that used this prompt.
|
||||
#[diesel(sql_type = Text)]
|
||||
pub template: String,
|
||||
/// `created_at` of the newest job that used it.
|
||||
#[diesel(sql_type = BigInt)]
|
||||
pub last_used: i64,
|
||||
#[diesel(sql_type = BigInt)]
|
||||
pub uses: i64,
|
||||
}
|
||||
discarded, created_at, updated_at";
|
||||
|
||||
/// What the poller / approve changes on an existing job.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -93,8 +75,6 @@ pub struct JobUpdate {
|
||||
pub pending: Option<String>,
|
||||
/// `Some` replaces the `saved` document.
|
||||
pub saved: Option<String>,
|
||||
/// `Some` sets the run time; `None` keeps any existing value.
|
||||
pub run_ms: Option<i64>,
|
||||
}
|
||||
|
||||
impl JobUpdate {
|
||||
@@ -105,7 +85,6 @@ impl JobUpdate {
|
||||
queue_position: None,
|
||||
pending: None,
|
||||
saved: None,
|
||||
run_ms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,20 +100,7 @@ pub trait EnhanceJobDao: Sync + Send {
|
||||
) -> Result<Vec<EnhanceJobRow>, DbError>;
|
||||
/// Jobs the poller still has to advance (`queued` / `running`).
|
||||
fn list_active(&mut self) -> Result<Vec<EnhanceJobRow>, DbError>;
|
||||
/// The jobs manager's list, across photos, newest first, discarded hidden:
|
||||
/// every queued / running / unavailable job, plus ready / failed / expired
|
||||
/// ones last touched at or after `since` (unix seconds). Approved jobs are
|
||||
/// done and never listed.
|
||||
fn list_open(&mut self, since: i64, limit: i64) -> Result<Vec<EnhanceJobRow>, DbError>;
|
||||
fn update(&mut self, id: &str, update: &JobUpdate) -> Result<(), DbError>;
|
||||
/// Distinct non-empty prompts (compared trimmed), newest use first.
|
||||
/// Counts every job, discarded and failed included. `templates` limits
|
||||
/// to those template names; `None` means all.
|
||||
fn recent_prompts(
|
||||
&mut self,
|
||||
templates: Option<&[String]>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<RecentPrompt>, DbError>;
|
||||
/// Hide a job from `list_for_hash`.
|
||||
fn discard(&mut self, id: &str) -> Result<(), DbError>;
|
||||
/// Delete jobs last touched before `cutoff` (unix seconds) that are not
|
||||
@@ -171,7 +137,7 @@ impl EnhanceJobDao for SqliteEnhanceJobDao {
|
||||
trace_db_call("insert", "enhance_job_insert", |_span| {
|
||||
let mut conn = self.connection.lock().expect("Unable to get EnhanceJobDao");
|
||||
sql_query(format!(
|
||||
"INSERT INTO enhance_jobs ({COLUMNS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||
"INSERT INTO enhance_jobs ({COLUMNS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||
))
|
||||
.bind::<Text, _>(&job.id)
|
||||
.bind::<Text, _>(&job.original_hash)
|
||||
@@ -190,7 +156,6 @@ impl EnhanceJobDao for SqliteEnhanceJobDao {
|
||||
.bind::<Integer, _>(job.discarded)
|
||||
.bind::<BigInt, _>(job.created_at)
|
||||
.bind::<BigInt, _>(job.updated_at)
|
||||
.bind::<Nullable<BigInt>, _>(job.run_ms)
|
||||
.execute(&mut *conn)
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))
|
||||
@@ -242,30 +207,12 @@ impl EnhanceJobDao for SqliteEnhanceJobDao {
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn list_open(&mut self, since: i64, limit: i64) -> Result<Vec<EnhanceJobRow>, DbError> {
|
||||
trace_db_call("query", "enhance_job_list_open", |_span| {
|
||||
let mut conn = self.connection.lock().expect("Unable to get EnhanceJobDao");
|
||||
sql_query(format!(
|
||||
"SELECT {COLUMNS} FROM enhance_jobs WHERE discarded = 0 AND ( \
|
||||
status IN ('queued', 'running', 'unavailable') \
|
||||
OR (status IN ('ready', 'failed', 'expired') AND updated_at >= ?)) \
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?"
|
||||
))
|
||||
.bind::<BigInt, _>(since)
|
||||
.bind::<BigInt, _>(limit)
|
||||
.load::<EnhanceJobRow>(&mut *conn)
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn update(&mut self, id: &str, u: &JobUpdate) -> Result<(), DbError> {
|
||||
trace_db_call("update", "enhance_job_update", |_span| {
|
||||
let mut conn = self.connection.lock().expect("Unable to get EnhanceJobDao");
|
||||
sql_query(
|
||||
"UPDATE enhance_jobs SET status = ?, error = ?, queue_position = ?, \
|
||||
pending = COALESCE(?, pending), saved = COALESCE(?, saved), run_ms = COALESCE(?, run_ms), \
|
||||
updated_at = ? \
|
||||
pending = COALESCE(?, pending), saved = COALESCE(?, saved), updated_at = ? \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind::<Text, _>(&u.status)
|
||||
@@ -273,7 +220,6 @@ impl EnhanceJobDao for SqliteEnhanceJobDao {
|
||||
.bind::<Nullable<Integer>, _>(u.queue_position)
|
||||
.bind::<Nullable<Text>, _>(&u.pending)
|
||||
.bind::<Nullable<Text>, _>(&u.saved)
|
||||
.bind::<Nullable<BigInt>, _>(u.run_ms)
|
||||
.bind::<BigInt, _>(chrono::Utc::now().timestamp())
|
||||
.bind::<Text, _>(id)
|
||||
.execute(&mut *conn)
|
||||
@@ -283,36 +229,6 @@ impl EnhanceJobDao for SqliteEnhanceJobDao {
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
}
|
||||
|
||||
fn recent_prompts(
|
||||
&mut self,
|
||||
templates: Option<&[String]>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<RecentPrompt>, DbError> {
|
||||
trace_db_call("query", "enhance_job_recent_prompts", |_span| {
|
||||
let mut conn = self.connection.lock().expect("Unable to get EnhanceJobDao");
|
||||
let names = serde_json::to_string(templates.unwrap_or(&[]))
|
||||
.map_err(|e| anyhow::anyhow!("Encode error: {}", e))?;
|
||||
// `template` and `MAX(created_at)` are bare/aggregate in the same
|
||||
// group, so SQLite takes `template` from the newest row.
|
||||
sql_query(
|
||||
"SELECT TRIM(prompt, ' ' || char(9) || char(10) || char(13)) AS prompt, \
|
||||
template, MAX(created_at) AS last_used, COUNT(*) AS uses \
|
||||
FROM enhance_jobs \
|
||||
WHERE prompt IS NOT NULL \
|
||||
AND TRIM(prompt, ' ' || char(9) || char(10) || char(13)) <> '' \
|
||||
AND (? = 0 OR template IN (SELECT value FROM json_each(?))) \
|
||||
GROUP BY TRIM(prompt, ' ' || char(9) || char(10) || char(13)) \
|
||||
ORDER BY last_used DESC, MAX(rowid) DESC LIMIT ?",
|
||||
)
|
||||
.bind::<Integer, _>(i32::from(templates.is_some()))
|
||||
.bind::<Text, _>(names)
|
||||
.bind::<BigInt, _>(limit)
|
||||
.load::<RecentPrompt>(&mut *conn)
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn discard(&mut self, id: &str) -> Result<(), DbError> {
|
||||
trace_db_call("update", "enhance_job_discard", |_span| {
|
||||
let mut conn = self.connection.lock().expect("Unable to get EnhanceJobDao");
|
||||
@@ -369,7 +285,6 @@ mod tests {
|
||||
discarded: 0,
|
||||
created_at: created,
|
||||
updated_at: created,
|
||||
run_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,18 +318,10 @@ mod tests {
|
||||
queue_position: None,
|
||||
pending: Some("[{\"id\":\"x\"}]".into()),
|
||||
saved: None,
|
||||
run_ms: Some(4200),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let a = d.get("a").unwrap().unwrap();
|
||||
assert_eq!(a.run_ms, Some(4200));
|
||||
d.update("a", &JobUpdate::status("ready")).unwrap();
|
||||
assert_eq!(
|
||||
d.get("a").unwrap().unwrap().run_ms,
|
||||
Some(4200),
|
||||
"a null update never overwrites run_ms"
|
||||
);
|
||||
assert_eq!(a.status, "ready");
|
||||
assert_eq!(a.pending, "[{\"id\":\"x\"}]");
|
||||
assert_eq!(a.saved, "[]", "unset columns are left alone");
|
||||
@@ -433,87 +340,4 @@ mod tests {
|
||||
assert!(d.get("c").unwrap().is_some());
|
||||
assert!(d.get("b").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_open_spans_photos_and_drops_done_old_and_discarded() {
|
||||
let mut d = dao();
|
||||
let mut old_ready = job("old", "h1", "ready", 10);
|
||||
old_ready.updated_at = 10;
|
||||
d.insert(&old_ready).unwrap();
|
||||
let mut old_queued = job("stuck", "h2", "queued", 20);
|
||||
old_queued.updated_at = 20;
|
||||
d.insert(&old_queued).unwrap();
|
||||
d.insert(&job("ready", "h1", "ready", 200)).unwrap();
|
||||
d.insert(&job("failed", "h2", "failed", 300)).unwrap();
|
||||
d.insert(&job("done", "h3", "approved", 400)).unwrap();
|
||||
d.insert(&job("gone", "h3", "ready", 500)).unwrap();
|
||||
d.discard("gone").unwrap();
|
||||
d.insert(&job("down", "h4", "unavailable", 600)).unwrap();
|
||||
|
||||
let ids =
|
||||
|rows: Vec<EnhanceJobRow>| -> Vec<String> { rows.into_iter().map(|j| j.id).collect() };
|
||||
// discard() bumps updated_at to now, so `since` well below now still
|
||||
// can't bring it back.
|
||||
assert_eq!(
|
||||
ids(d.list_open(100, 50).unwrap()),
|
||||
["down", "failed", "ready", "stuck"],
|
||||
"newest first; active always, finished only since the cutoff"
|
||||
);
|
||||
assert_eq!(ids(d.list_open(100, 2).unwrap()), ["down", "failed"]);
|
||||
}
|
||||
|
||||
fn prompt_job(id: &str, template: &str, prompt: Option<&str>, created: i64) -> EnhanceJobRow {
|
||||
EnhanceJobRow {
|
||||
template: template.into(),
|
||||
prompt: prompt.map(str::to_string),
|
||||
..job(id, "h", "ready", created)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_prompts_groups_trimmed_newest_first() {
|
||||
let mut d = dao();
|
||||
d.insert(&prompt_job("a", "edit", Some("warmer"), 100))
|
||||
.unwrap();
|
||||
d.insert(&prompt_job("b", "edit-fast", Some(" warmer\n"), 300))
|
||||
.unwrap();
|
||||
d.insert(&prompt_job("c", "edit", Some("remove the sign"), 200))
|
||||
.unwrap();
|
||||
d.insert(&prompt_job("d", "edit", Some(" "), 400))
|
||||
.unwrap();
|
||||
d.insert(&prompt_job("e", "edit", None, 500)).unwrap();
|
||||
d.insert(&prompt_job("f", "restore", Some("older"), 50))
|
||||
.unwrap();
|
||||
// Failed and discarded jobs still count.
|
||||
d.update("b", &JobUpdate::status("failed")).unwrap();
|
||||
d.discard("a").unwrap();
|
||||
|
||||
let all = d.recent_prompts(None, 10).unwrap();
|
||||
let view: Vec<_> = all
|
||||
.iter()
|
||||
.map(|p| (p.prompt.as_str(), p.template.as_str(), p.last_used, p.uses))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
view,
|
||||
[
|
||||
("warmer", "edit-fast", 300, 2),
|
||||
("remove the sign", "edit", 200, 1),
|
||||
("older", "restore", 50, 1),
|
||||
]
|
||||
);
|
||||
|
||||
let edit = d
|
||||
.recent_prompts(Some(&["edit".into(), "nope".into()]), 10)
|
||||
.unwrap();
|
||||
let view: Vec<_> = edit.iter().map(|p| (p.prompt.as_str(), p.uses)).collect();
|
||||
assert_eq!(view, [("remove the sign", 1), ("warmer", 1)]);
|
||||
|
||||
assert!(
|
||||
d.recent_prompts(Some(&["nope".into()]), 10)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(d.recent_prompts(Some(&[]), 10).unwrap().is_empty());
|
||||
assert_eq!(d.recent_prompts(None, 1).unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
+8
-39
@@ -323,11 +323,10 @@ impl FavoriteDao for SqliteFavoriteDao {
|
||||
|
||||
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(), None, favorite_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| favorite_path.to_string());
|
||||
let favorite_path = &version_dao::canonical_rel_path(connection.deref_mut(), favorite_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| favorite_path.to_string());
|
||||
|
||||
if favorites
|
||||
.filter(userid.eq(user_id).and(rel_path.eq(&favorite_path)))
|
||||
@@ -350,11 +349,10 @@ impl FavoriteDao for SqliteFavoriteDao {
|
||||
use schema::favorites::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().unwrap();
|
||||
let favorite_path =
|
||||
version_dao::canonical_rel_path(connection.deref_mut(), None, &favorite_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(favorite_path);
|
||||
let favorite_path = version_dao::canonical_rel_path(connection.deref_mut(), &favorite_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(favorite_path);
|
||||
diesel::delete(favorites)
|
||||
.filter(userid.eq(user_id).and(rel_path.eq(favorite_path)))
|
||||
.execute(connection.deref_mut())
|
||||
@@ -364,10 +362,8 @@ impl FavoriteDao for SqliteFavoriteDao {
|
||||
fn get_favorites(&mut self, user_id: i32) -> Result<Vec<Favorite>, DbError> {
|
||||
use schema::favorites::dsl::*;
|
||||
|
||||
// Newest first, so a Favorites view lists what was just added on top.
|
||||
favorites
|
||||
.filter(userid.eq(user_id))
|
||||
.order(id.desc())
|
||||
.load::<Favorite>(self.connection.lock().unwrap().deref_mut())
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
@@ -3458,30 +3454,3 @@ mod exif_dao_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod favorite_dao_tests {
|
||||
use super::*;
|
||||
use crate::database::test::in_memory_db_connection;
|
||||
|
||||
#[test]
|
||||
fn favorites_list_newest_first() {
|
||||
let mut dao =
|
||||
SqliteFavoriteDao::from_shared(Arc::new(Mutex::new(in_memory_db_connection())));
|
||||
for p in ["b.jpg", "c.jpg", "a.jpg"] {
|
||||
dao.add_favorite(1, p).unwrap();
|
||||
}
|
||||
dao.add_favorite(2, "z.jpg").unwrap();
|
||||
let paths: Vec<String> = dao
|
||||
.get_favorites(1)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|f| f.path)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
["a.jpg", "c.jpg", "b.jpg"],
|
||||
"insertion order, newest first; per user"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,6 @@ diesel::table! {
|
||||
discarded -> Integer,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
run_ms -> Nullable<BigInt>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-349
@@ -20,32 +20,7 @@ use crate::database::{DbError, DbErrorKind, connect};
|
||||
use crate::otel::trace_db_call;
|
||||
|
||||
/// The `kind` values a version may carry.
|
||||
pub const KINDS: &[&str] = &["restored", "upscale", "edit", "animated"];
|
||||
|
||||
/// Kinds that are *derived clips* rather than versions: a video made from a
|
||||
/// photo (Enhance "Animate"). Linked and hidden behind the photo like a
|
||||
/// version, but never counted as one, never a display choice, and listed
|
||||
/// separately (`clips` in `GET /image/versions`).
|
||||
pub const CLIP_KINDS: &[&str] = &["animated"];
|
||||
|
||||
pub fn is_clip_kind(kind: &str) -> bool {
|
||||
CLIP_KINDS.contains(&kind)
|
||||
}
|
||||
|
||||
/// `pv.kind NOT IN (<clip kinds>)`, for queries about versions proper.
|
||||
fn not_a_clip_sql(alias: &str) -> String {
|
||||
format!("{alias}.kind NOT IN ({})", clip_kinds_sql())
|
||||
}
|
||||
|
||||
/// `pv.kind IN (<clip kinds>)`.
|
||||
fn is_a_clip_sql(alias: &str) -> String {
|
||||
format!("{alias}.kind IN ({})", clip_kinds_sql())
|
||||
}
|
||||
|
||||
fn clip_kinds_sql() -> String {
|
||||
let kinds: Vec<String> = CLIP_KINDS.iter().map(|k| format!("'{k}'")).collect();
|
||||
kinds.join(", ")
|
||||
}
|
||||
pub const KINDS: &[&str] = &["restored", "upscale", "edit"];
|
||||
|
||||
/// `date_taken_source` on a version row whose date, GPS and camera fields
|
||||
/// were copied from its original.
|
||||
@@ -190,14 +165,11 @@ pub struct HashedFile {
|
||||
pub rel_path: String,
|
||||
}
|
||||
|
||||
/// What `/photos` adds next to an original: how many live versions and
|
||||
/// derived clips it has and, if the global choice isn't the original, the
|
||||
/// file to display.
|
||||
/// What `/photos` adds next to an original: how many live versions it has
|
||||
/// and, if the global choice isn't the original, the file to display.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PhotoVersionSummary {
|
||||
pub version_count: u32,
|
||||
/// Live derived clips (`CLIP_KINDS`); never part of `version_count`.
|
||||
pub clip_count: u32,
|
||||
/// `(library_id, rel_path)` of the chosen version file; the library is
|
||||
/// always the original's own.
|
||||
pub display: Option<(i32, String)>,
|
||||
@@ -216,15 +188,6 @@ pub trait VersionDao: Sync + Send {
|
||||
/// the original's rel_path (same library when possible).
|
||||
fn original_path_of(&mut self, rel_path: &str) -> Result<Option<String>, DbError>;
|
||||
|
||||
/// `(library_id, rel_path)` of the original of the version at
|
||||
/// `(library_id, rel_path)`, when it is a linked version with a live
|
||||
/// original. For per-library writes that must land on the original.
|
||||
fn original_location_of(
|
||||
&mut self,
|
||||
library_id: i32,
|
||||
rel_path: &str,
|
||||
) -> Result<Option<(i32, String)>, DbError>;
|
||||
|
||||
/// Which of `hashes` are versions of something.
|
||||
fn version_hashes_among(&mut self, hashes: &[String]) -> Result<HashSet<String>, DbError>;
|
||||
|
||||
@@ -265,28 +228,14 @@ pub trait VersionDao: Sync + Send {
|
||||
view_library_ids: &[i32],
|
||||
) -> Result<HashSet<(i32, String)>, DbError>;
|
||||
|
||||
/// Of `dirs` (rel paths), those that hold indexed files and where every
|
||||
/// indexed file anywhere underneath is a version hidden in the view.
|
||||
/// Such a folder would open empty. A folder with no indexed rows at all
|
||||
/// is not reported (nothing says it is safe to hide). One indexed range
|
||||
/// scan per dir; no filesystem walk.
|
||||
fn dirs_holding_only_hidden_versions(
|
||||
&mut self,
|
||||
view_library_ids: &[i32],
|
||||
dirs: &[String],
|
||||
) -> Result<HashSet<String>, DbError>;
|
||||
|
||||
/// Rows in a library whose file name follows the version convention
|
||||
/// (`<name>.<kind>[.<n>].<ext>`), have a hash and aren't linked yet.
|
||||
/// Returns `(id, rel_path, content_hash)` ordered by row id, starting
|
||||
/// after `after_id` (the caller's resume cursor, so rows that never
|
||||
/// resolve can't starve the ones behind them).
|
||||
/// Returns `(rel_path, content_hash)`, oldest first.
|
||||
fn unlinked_name_candidates(
|
||||
&mut self,
|
||||
library_id: i32,
|
||||
after_id: i32,
|
||||
limit: i64,
|
||||
) -> Result<Vec<(i32, String, String)>, DbError>;
|
||||
) -> Result<Vec<(String, String)>, DbError>;
|
||||
|
||||
/// Copy the original's date, GPS and camera fields onto every version
|
||||
/// row whose copy is stale. Returns the number of rows updated.
|
||||
@@ -330,93 +279,26 @@ fn placeholders(n: usize) -> String {
|
||||
/// If `rel_path` is a linked version whose original has a live row, the
|
||||
/// original's rel_path (same library when possible). Tag and favorite
|
||||
/// writes on a version path bind to the original through this.
|
||||
///
|
||||
/// `library_id` scopes the version lookup to one library's row; `None` is
|
||||
/// for the library-agnostic tag/favorite DAOs, which only know a rel_path.
|
||||
/// Annotations are keyed by rel_path alone, so in either case a path that
|
||||
/// *also* names a file in some library that is not a linked version stays
|
||||
/// itself: redirecting it would hijack that photo's own tags and favorites.
|
||||
pub fn canonical_rel_path(
|
||||
conn: &mut SqliteConnection,
|
||||
library_id: Option<i32>,
|
||||
rel_path: &str,
|
||||
) -> QueryResult<Option<String>> {
|
||||
Ok(canonical_location(conn, library_id, rel_path)?.map(|(_, p)| p))
|
||||
}
|
||||
|
||||
/// [`canonical_rel_path`] with the original's library too, for callers that
|
||||
/// write per-library rows (the manual date lives on the `image_exif` row).
|
||||
pub fn canonical_location(
|
||||
conn: &mut SqliteConnection,
|
||||
library_id: Option<i32>,
|
||||
rel_path: &str,
|
||||
) -> QueryResult<Option<(i32, String)>> {
|
||||
#[derive(QueryableByName)]
|
||||
struct Row {
|
||||
#[diesel(sql_type = Integer)]
|
||||
library_id: i32,
|
||||
#[diesel(sql_type = Text)]
|
||||
rel_path: String,
|
||||
}
|
||||
let rows = sql_query(
|
||||
"SELECT o.library_id AS library_id, o.rel_path AS rel_path FROM image_exif v \
|
||||
"SELECT o.rel_path AS rel_path FROM image_exif v \
|
||||
JOIN photo_versions pv ON pv.version_hash = v.content_hash \
|
||||
JOIN image_exif o ON o.content_hash = pv.original_hash \
|
||||
WHERE v.rel_path = ?1 AND (?2 IS NULL OR v.library_id = ?2) \
|
||||
AND NOT EXISTS (SELECT 1 FROM image_exif x WHERE x.rel_path = v.rel_path \
|
||||
AND NOT EXISTS (SELECT 1 FROM photo_versions p \
|
||||
WHERE p.version_hash = x.content_hash)) \
|
||||
WHERE v.rel_path = ? \
|
||||
ORDER BY (o.library_id = v.library_id) DESC, o.id \
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind::<Text, _>(rel_path)
|
||||
.bind::<Nullable<Integer>, _>(library_id)
|
||||
.load::<Row>(conn)?;
|
||||
Ok(rows.into_iter().next().map(|r| (r.library_id, r.rel_path)))
|
||||
}
|
||||
|
||||
/// Fold favorites and tags recorded on a version's own path into its
|
||||
/// original's, now that reads and writes on the version path canonicalise
|
||||
/// to the original and could never reach them again. Set-valued data is
|
||||
/// unioned (duplicates dropped), then the version path's rows are removed.
|
||||
/// A no-op unless the version has a live original (`canonical_rel_path`).
|
||||
fn merge_annotations_into_original(
|
||||
conn: &mut SqliteConnection,
|
||||
library_id: i32,
|
||||
version_rel_path: &str,
|
||||
) -> QueryResult<()> {
|
||||
let Some(original) = canonical_rel_path(conn, Some(library_id), version_rel_path)? else {
|
||||
return Ok(());
|
||||
};
|
||||
if original == version_rel_path {
|
||||
return Ok(());
|
||||
}
|
||||
sql_query(
|
||||
"INSERT INTO favorites (userid, rel_path) \
|
||||
SELECT f.userid, ?2 FROM favorites f WHERE f.rel_path = ?1 \
|
||||
AND NOT EXISTS (SELECT 1 FROM favorites g WHERE g.userid = f.userid AND g.rel_path = ?2)",
|
||||
)
|
||||
.bind::<Text, _>(version_rel_path)
|
||||
.bind::<Text, _>(&original)
|
||||
.execute(conn)?;
|
||||
sql_query("DELETE FROM favorites WHERE rel_path = ?")
|
||||
.bind::<Text, _>(version_rel_path)
|
||||
.execute(conn)?;
|
||||
sql_query(
|
||||
"INSERT INTO tagged_photo (rel_path, tag_id, created_time, content_hash) \
|
||||
SELECT ?2, t.tag_id, t.created_time, \
|
||||
(SELECT content_hash FROM image_exif WHERE rel_path = ?2 \
|
||||
AND content_hash IS NOT NULL LIMIT 1) \
|
||||
FROM tagged_photo t WHERE t.rel_path = ?1 \
|
||||
AND NOT EXISTS (SELECT 1 FROM tagged_photo u WHERE u.tag_id = t.tag_id AND u.rel_path = ?2)",
|
||||
)
|
||||
.bind::<Text, _>(version_rel_path)
|
||||
.bind::<Text, _>(&original)
|
||||
.execute(conn)?;
|
||||
sql_query("DELETE FROM tagged_photo WHERE rel_path = ?")
|
||||
.bind::<Text, _>(version_rel_path)
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
Ok(rows.into_iter().next().map(|r| r.rel_path))
|
||||
}
|
||||
|
||||
/// Root original of `hash`: follows `photo_versions` until a hash that is
|
||||
@@ -539,8 +421,6 @@ impl VersionDao for SqliteVersionDao {
|
||||
.bind::<Text, _>(reference)
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
merge_annotations_into_original(conn, link.library_id, &link.rel_path)?;
|
||||
Ok(LinkOutcome::Linked)
|
||||
})
|
||||
})
|
||||
@@ -565,20 +445,7 @@ impl VersionDao for SqliteVersionDao {
|
||||
fn original_path_of(&mut self, rel_path: &str) -> Result<Option<String>, DbError> {
|
||||
trace_db_call("query", "original_path_of", |_span| {
|
||||
let mut conn = self.connection.lock().expect("Unable to get VersionDao");
|
||||
canonical_rel_path(&mut conn, None, rel_path)
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn original_location_of(
|
||||
&mut self,
|
||||
library_id: i32,
|
||||
rel_path: &str,
|
||||
) -> Result<Option<(i32, String)>, DbError> {
|
||||
trace_db_call("query", "original_location_of", |_span| {
|
||||
let mut conn = self.connection.lock().expect("Unable to get VersionDao");
|
||||
canonical_location(&mut conn, Some(library_id), rel_path)
|
||||
canonical_rel_path(&mut conn, rel_path)
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
@@ -747,8 +614,6 @@ impl VersionDao for SqliteVersionDao {
|
||||
rel_path: String,
|
||||
#[diesel(sql_type = BigInt)]
|
||||
version_count: i64,
|
||||
#[diesel(sql_type = BigInt)]
|
||||
clip_count: i64,
|
||||
}
|
||||
#[derive(QueryableByName)]
|
||||
struct DisplayRow {
|
||||
@@ -761,24 +626,16 @@ impl VersionDao for SqliteVersionDao {
|
||||
}
|
||||
let mut conn = self.connection.lock().expect("Unable to get VersionDao");
|
||||
|
||||
let originals = sql_query(format!(
|
||||
let originals = sql_query(
|
||||
"SELECT o.content_hash AS original_hash, o.library_id AS library_id, \
|
||||
o.rel_path AS rel_path, \
|
||||
(SELECT COUNT(*) FROM photo_versions pv \
|
||||
WHERE pv.original_hash = o.content_hash \
|
||||
AND {not_clip} \
|
||||
AND EXISTS (SELECT 1 FROM image_exif v \
|
||||
WHERE v.content_hash = pv.version_hash)) AS version_count, \
|
||||
(SELECT COUNT(*) FROM photo_versions pv \
|
||||
WHERE pv.original_hash = o.content_hash \
|
||||
AND {is_clip} \
|
||||
AND EXISTS (SELECT 1 FROM image_exif v \
|
||||
WHERE v.content_hash = pv.version_hash)) AS clip_count \
|
||||
WHERE v.content_hash = pv.version_hash)) AS version_count \
|
||||
FROM image_exif o \
|
||||
WHERE o.content_hash IN (SELECT original_hash FROM photo_versions)",
|
||||
not_clip = not_a_clip_sql("pv"),
|
||||
is_clip = is_a_clip_sql("pv"),
|
||||
))
|
||||
)
|
||||
.load::<OriginalRow>(&mut *conn)
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?;
|
||||
if originals.is_empty() {
|
||||
@@ -787,17 +644,15 @@ impl VersionDao for SqliteVersionDao {
|
||||
|
||||
// Only a display choice that is still a version of that original
|
||||
// counts; anything else falls back to the original.
|
||||
let displays = sql_query(format!(
|
||||
let displays = sql_query(
|
||||
"SELECT d.original_hash AS original_hash, v.library_id AS library_id, \
|
||||
v.rel_path AS rel_path \
|
||||
FROM photo_version_display d \
|
||||
JOIN photo_versions pv ON pv.version_hash = d.display_hash \
|
||||
AND pv.original_hash = d.original_hash \
|
||||
AND {not_clip} \
|
||||
JOIN image_exif v ON v.content_hash = d.display_hash \
|
||||
ORDER BY v.id",
|
||||
not_clip = not_a_clip_sql("pv"),
|
||||
))
|
||||
)
|
||||
.load::<DisplayRow>(&mut *conn)
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?;
|
||||
let mut display_by_hash: HashMap<String, Vec<(i32, String)>> = HashMap::new();
|
||||
@@ -810,7 +665,7 @@ impl VersionDao for SqliteVersionDao {
|
||||
|
||||
let mut out = HashMap::new();
|
||||
for o in originals {
|
||||
if o.version_count == 0 && o.clip_count == 0 {
|
||||
if o.version_count == 0 {
|
||||
continue;
|
||||
}
|
||||
// Clients fetch the display file through the original's
|
||||
@@ -823,7 +678,6 @@ impl VersionDao for SqliteVersionDao {
|
||||
(o.library_id, o.rel_path),
|
||||
PhotoVersionSummary {
|
||||
version_count: o.version_count as u32,
|
||||
clip_count: o.clip_count as u32,
|
||||
display,
|
||||
},
|
||||
);
|
||||
@@ -866,82 +720,14 @@ impl VersionDao for SqliteVersionDao {
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn dirs_holding_only_hidden_versions(
|
||||
&mut self,
|
||||
view_library_ids: &[i32],
|
||||
dirs: &[String],
|
||||
) -> Result<HashSet<String>, DbError> {
|
||||
if dirs.is_empty() {
|
||||
return Ok(HashSet::new());
|
||||
}
|
||||
trace_db_call("query", "dirs_holding_only_hidden_versions", |_span| {
|
||||
#[derive(QueryableByName)]
|
||||
struct Counts {
|
||||
#[diesel(sql_type = BigInt)]
|
||||
total: i64,
|
||||
#[diesel(sql_type = BigInt)]
|
||||
visible: i64,
|
||||
}
|
||||
#[derive(QueryableByName)]
|
||||
struct Id {
|
||||
#[diesel(sql_type = Integer)]
|
||||
id: i32,
|
||||
}
|
||||
let mut conn = self.connection.lock().expect("Unable to get VersionDao");
|
||||
// Scan the view's libraries (all of them when unscoped) so the
|
||||
// (library_id, rel_path) index serves the prefix range.
|
||||
let scan: Vec<i32> = if view_library_ids.is_empty() {
|
||||
sql_query("SELECT id FROM libraries")
|
||||
.load::<Id>(&mut *conn)?
|
||||
.into_iter()
|
||||
.map(|r| r.id)
|
||||
.collect()
|
||||
} else {
|
||||
view_library_ids.to_vec()
|
||||
};
|
||||
let list = scan
|
||||
.iter()
|
||||
.map(i32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) AS total, \
|
||||
COALESCE(SUM(CASE WHEN {} THEN 1 ELSE 0 END), 0) AS visible \
|
||||
FROM image_exif \
|
||||
WHERE library_id IN ({list}) AND rel_path >= ? AND rel_path < ?",
|
||||
visible_version_clause(view_library_ids)
|
||||
);
|
||||
let mut out = HashSet::new();
|
||||
for dir in dirs {
|
||||
let dir = dir.trim_end_matches('/');
|
||||
if dir.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// '0' is the character after '/', closing the prefix range.
|
||||
let c = sql_query(&sql)
|
||||
.bind::<Text, _>(format!("{dir}/"))
|
||||
.bind::<Text, _>(format!("{dir}0"))
|
||||
.get_result::<Counts>(&mut *conn)?;
|
||||
if c.total > 0 && c.visible == 0 {
|
||||
out.insert(dir.to_string());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn unlinked_name_candidates(
|
||||
&mut self,
|
||||
library_id: i32,
|
||||
after_id: i32,
|
||||
limit: i64,
|
||||
) -> Result<Vec<(i32, String, String)>, DbError> {
|
||||
) -> Result<Vec<(String, String)>, DbError> {
|
||||
trace_db_call("query", "unlinked_name_candidates", |_span| {
|
||||
#[derive(QueryableByName)]
|
||||
struct Row {
|
||||
#[diesel(sql_type = Integer)]
|
||||
id: i32,
|
||||
#[diesel(sql_type = Text)]
|
||||
rel_path: String,
|
||||
#[diesel(sql_type = Text)]
|
||||
@@ -953,8 +739,8 @@ impl VersionDao for SqliteVersionDao {
|
||||
.collect::<Vec<_>>()
|
||||
.join(" OR ");
|
||||
let sql = format!(
|
||||
"SELECT id, rel_path, content_hash FROM image_exif \
|
||||
WHERE library_id = ? AND id > ? AND content_hash IS NOT NULL AND ({kinds}) \
|
||||
"SELECT rel_path, content_hash FROM image_exif \
|
||||
WHERE library_id = ? AND content_hash IS NOT NULL AND ({kinds}) \
|
||||
AND NOT EXISTS (SELECT 1 FROM photo_versions pv \
|
||||
WHERE pv.version_hash = image_exif.content_hash) \
|
||||
ORDER BY id LIMIT ?"
|
||||
@@ -962,12 +748,11 @@ impl VersionDao for SqliteVersionDao {
|
||||
let mut conn = self.connection.lock().expect("Unable to get VersionDao");
|
||||
sql_query(sql)
|
||||
.bind::<Integer, _>(library_id)
|
||||
.bind::<Integer, _>(after_id)
|
||||
.bind::<BigInt, _>(limit)
|
||||
.load::<Row>(&mut *conn)
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| (r.id, r.rel_path, r.content_hash))
|
||||
.map(|r| (r.rel_path, r.content_hash))
|
||||
.collect()
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
@@ -1222,120 +1007,20 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut conn = c.lock().unwrap();
|
||||
assert_eq!(
|
||||
canonical_rel_path(&mut conn, None, "aiedits/orig.jpg.restored.png").unwrap(),
|
||||
canonical_rel_path(&mut conn, "aiedits/orig.jpg.restored.png").unwrap(),
|
||||
Some("orig.jpg".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_rel_path(&mut conn, None, "orig.jpg").unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(canonical_rel_path(&mut conn, "orig.jpg").unwrap(), None);
|
||||
drop(conn);
|
||||
// Original gone: nothing to bind to, the version is a normal photo.
|
||||
exec(&c, "DELETE FROM image_exif WHERE content_hash = 'o'");
|
||||
let mut conn = c.lock().unwrap();
|
||||
assert_eq!(
|
||||
canonical_rel_path(&mut conn, None, "aiedits/orig.jpg.restored.png").unwrap(),
|
||||
canonical_rel_path(&mut conn, "aiedits/orig.jpg.restored.png").unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_path_is_scoped_to_the_versions_library() {
|
||||
let (mut dao, c) = dao();
|
||||
insert_exif(&c, 1, "album_a/orig.jpg", "o");
|
||||
insert_exif(&c, 1, "album_a/aiedits/orig.jpg.edit.png", "v");
|
||||
dao.link_version(&link("v", "o", "album_a/aiedits/orig.jpg.edit.png"))
|
||||
.unwrap();
|
||||
// Library 2 has an unrelated, unlinked photo at the same path.
|
||||
insert_exif(&c, 2, "album_a/aiedits/orig.jpg.edit.png", "other");
|
||||
let mut conn = c.lock().unwrap();
|
||||
let p = "album_a/aiedits/orig.jpg.edit.png";
|
||||
assert_eq!(
|
||||
canonical_rel_path(&mut conn, Some(2), p).unwrap(),
|
||||
None,
|
||||
"library 2's row is not a version"
|
||||
);
|
||||
// Annotations are path-keyed, so a path that also names a
|
||||
// standalone photo is never redirected away from it.
|
||||
assert_eq!(canonical_rel_path(&mut conn, None, p).unwrap(), None);
|
||||
assert_eq!(
|
||||
canonical_rel_path(&mut conn, Some(1), p).unwrap(),
|
||||
None,
|
||||
"shared path with a standalone photo stays put"
|
||||
);
|
||||
drop(conn);
|
||||
exec(&c, "DELETE FROM image_exif WHERE content_hash = 'other'");
|
||||
let mut conn = c.lock().unwrap();
|
||||
assert_eq!(
|
||||
canonical_rel_path(&mut conn, Some(1), p).unwrap(),
|
||||
Some("album_a/orig.jpg".to_string())
|
||||
);
|
||||
assert_eq!(canonical_rel_path(&mut conn, Some(2), p).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linking_merges_annotations_from_the_version_path_into_the_original() {
|
||||
let (mut dao, c) = dao();
|
||||
insert_exif(&c, 1, "album_a/orig.jpg", "o");
|
||||
insert_exif(&c, 1, "album_a/aiedits/orig.jpg.edit.png", "v");
|
||||
let v = "album_a/aiedits/orig.jpg.edit.png";
|
||||
let o = "album_a/orig.jpg";
|
||||
for t in ["t1", "t2"] {
|
||||
exec(
|
||||
&c,
|
||||
&format!("INSERT INTO tags (name, created_time) VALUES ('{t}', 0)"),
|
||||
);
|
||||
}
|
||||
// Tag 1 on both paths (duplicate), tag 2 only on the version.
|
||||
exec(
|
||||
&c,
|
||||
&format!(
|
||||
"INSERT INTO tagged_photo (rel_path, tag_id, created_time) \
|
||||
SELECT '{o}', id, 1 FROM tags WHERE name = 't1'"
|
||||
),
|
||||
);
|
||||
exec(
|
||||
&c,
|
||||
&format!(
|
||||
"INSERT INTO tagged_photo (rel_path, tag_id, created_time) \
|
||||
SELECT '{v}', id, 2 FROM tags"
|
||||
),
|
||||
);
|
||||
// User 1 favorited both; user 2 only the version.
|
||||
exec(
|
||||
&c,
|
||||
&format!(
|
||||
"INSERT INTO favorites (userid, rel_path) VALUES (1, '{o}'), (1, '{v}'), (2, '{v}')"
|
||||
),
|
||||
);
|
||||
|
||||
dao.link_version(&link("v", "o", v)).unwrap();
|
||||
|
||||
let rows = |sql: &str| -> Vec<(String, i32)> {
|
||||
#[derive(QueryableByName)]
|
||||
struct R {
|
||||
#[diesel(sql_type = Text)]
|
||||
p: String,
|
||||
#[diesel(sql_type = Integer)]
|
||||
k: i32,
|
||||
}
|
||||
sql_query(sql)
|
||||
.load::<R>(&mut *c.lock().unwrap())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|r| (r.p, r.k))
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
rows("SELECT rel_path AS p, userid AS k FROM favorites ORDER BY userid"),
|
||||
vec![(o.to_string(), 1), (o.to_string(), 2)]
|
||||
);
|
||||
assert_eq!(
|
||||
rows("SELECT rel_path AS p, tag_id AS k FROM tagged_photo ORDER BY tag_id"),
|
||||
vec![(o.to_string(), 1), (o.to_string(), 2)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_copies_date_gps_and_camera_from_a_converted_original() {
|
||||
let (mut dao, c) = dao();
|
||||
@@ -1404,18 +1089,8 @@ mod tests {
|
||||
insert_exif(&c, 2, "a/y.jpg.restored.png", "h4");
|
||||
dao.link_version(&link("h2", "ox", "a/x.jpg.edit.2.png"))
|
||||
.unwrap_or(LinkOutcome::Linked);
|
||||
let got = dao.unlinked_name_candidates(1, 0, 100).unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(
|
||||
(got[0].1.as_str(), got[0].2.as_str()),
|
||||
("a/x.jpg.restored.png", "h1")
|
||||
);
|
||||
// The cursor skips rows at or before it.
|
||||
assert!(
|
||||
dao.unlinked_name_candidates(1, got[0].0, 100)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let got = dao.unlinked_name_candidates(1, 100).unwrap();
|
||||
assert_eq!(got, vec![("a/x.jpg.restored.png".to_string(), "h1".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+11
-26
@@ -16,7 +16,6 @@
|
||||
//! 5-minute window picks them up). For a single-user personal tool
|
||||
//! that's the right trade-off.
|
||||
|
||||
use crate::error::ApiError;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -247,7 +246,7 @@ async fn list_exact_handler(
|
||||
match dao.list_duplicates_exact(library_id, include_resolved) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
return actix_web::ResponseError::error_response(&ApiError::internal(e));
|
||||
return HttpResponse::InternalServerError().body(format!("{:?}", e));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -288,7 +287,7 @@ async fn list_perceptual_handler(
|
||||
match dao.list_perceptual_candidates(library_id, include_resolved) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
return actix_web::ResponseError::error_response(&ApiError::internal(e));
|
||||
return HttpResponse::InternalServerError().body(format!("{:?}", e));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -315,8 +314,7 @@ async fn resolve_handler(
|
||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> impl Responder {
|
||||
if body.demoted.is_empty() {
|
||||
return ApiError::bad_request("no_files", "Choose at least one duplicate to hide")
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().body("demoted list is empty");
|
||||
}
|
||||
|
||||
let mut dao = exif_dao.lock().expect("exif dao lock");
|
||||
@@ -326,23 +324,14 @@ async fn resolve_handler(
|
||||
let survivor = match dao.lookup_duplicate_row(body.survivor.library_id, &body.survivor.rel_path)
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return ApiError::not_found("file_not_found", "The photo to keep no longer exists")
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
return ApiError::internal(e).into_response();
|
||||
}
|
||||
Ok(None) => return HttpResponse::NotFound().body("survivor not found"),
|
||||
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
|
||||
};
|
||||
|
||||
// Survivor must not itself be soft-marked — otherwise the modal is
|
||||
// pointing at a row we've already demoted, which would create a chain.
|
||||
if survivor.duplicate_of_hash.is_some() {
|
||||
return ApiError::conflict(
|
||||
"survivor_is_duplicate",
|
||||
"The photo to keep is already hidden as a duplicate",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::Conflict().body("survivor is itself soft-marked as a duplicate");
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
@@ -360,7 +349,7 @@ async fn resolve_handler(
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
return actix_web::ResponseError::error_response(&ApiError::internal(e));
|
||||
return HttpResponse::InternalServerError().body(format!("{:?}", e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -398,7 +387,7 @@ async fn resolve_handler(
|
||||
&survivor.content_hash,
|
||||
now,
|
||||
) {
|
||||
return ApiError::internal(e).into_response();
|
||||
return HttpResponse::InternalServerError().body(format!("{:?}", e));
|
||||
}
|
||||
|
||||
resolved_count += 1;
|
||||
@@ -419,7 +408,7 @@ async fn unresolve_handler(
|
||||
) -> impl Responder {
|
||||
let mut dao = exif_dao.lock().expect("exif dao lock");
|
||||
if let Err(e) = dao.clear_duplicate_of(body.library_id, &body.rel_path) {
|
||||
return ApiError::internal(e).into_response();
|
||||
return HttpResponse::InternalServerError().body(format!("{:?}", e));
|
||||
}
|
||||
|
||||
drop(dao);
|
||||
@@ -461,15 +450,11 @@ async fn list_folder_pairs_handler(
|
||||
let mut dao = exif_dao.lock().expect("exif dao lock");
|
||||
let dup_rows = match dao.list_duplicates_exact(library_id, include_resolved) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
return actix_web::ResponseError::error_response(&ApiError::internal(e));
|
||||
}
|
||||
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
|
||||
};
|
||||
let all_paths = match dao.list_image_paths(library_id, include_resolved) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
return actix_web::ResponseError::error_response(&ApiError::internal(e));
|
||||
}
|
||||
Err(e) => return HttpResponse::InternalServerError().body(format!("{:?}", e)),
|
||||
};
|
||||
(dup_rows, all_paths)
|
||||
};
|
||||
|
||||
@@ -112,8 +112,6 @@ pub struct HistoryEntry {
|
||||
pub staged: Vec<Staged>,
|
||||
/// Last event time, unix millis.
|
||||
pub timestamp_ms: Option<i64>,
|
||||
/// How long ComfyUI ran it: end event minus `execution_start`, in ms.
|
||||
pub run_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
@@ -400,22 +398,6 @@ pub(crate) fn parse_history_entry(entry: &Value) -> HistoryEntry {
|
||||
.filter_map(|m| m.get(1)?.get("timestamp")?.as_i64())
|
||||
.max();
|
||||
|
||||
let event_ts = |pred: &dyn Fn(&str) -> bool| {
|
||||
messages
|
||||
.iter()
|
||||
.filter(|m| m.get(0).and_then(Value::as_str).is_some_and(pred))
|
||||
.find_map(|m| m.get(1)?.get("timestamp")?.as_i64())
|
||||
};
|
||||
let run_ms = event_ts(&|k| k == "execution_start")
|
||||
.zip(event_ts(&|k| {
|
||||
matches!(
|
||||
k,
|
||||
"execution_success" | "execution_error" | "execution_interrupted"
|
||||
)
|
||||
}))
|
||||
.map(|(start, end)| end - start)
|
||||
.filter(|ms| *ms >= 0);
|
||||
|
||||
let mut staged = Vec::new();
|
||||
if let Some(outputs) = entry["outputs"].as_object() {
|
||||
for out in outputs.values() {
|
||||
@@ -442,52 +424,5 @@ pub(crate) fn parse_history_entry(entry: &Value) -> HistoryEntry {
|
||||
error,
|
||||
staged,
|
||||
timestamp_ms,
|
||||
run_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn entry(messages: Value) -> Value {
|
||||
json!({"status": {"status_str": "success", "completed": true, "messages": messages}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_ms_is_end_minus_start_on_success() {
|
||||
let e = parse_history_entry(&entry(json!([
|
||||
["execution_start", {"timestamp": 1000}],
|
||||
["execution_success", {"timestamp": 8500}]
|
||||
])));
|
||||
assert_eq!(e.run_ms, Some(7500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_ms_is_measured_on_error_too() {
|
||||
let e = parse_history_entry(&entry(json!([
|
||||
["execution_start", {"timestamp": 1000}],
|
||||
["execution_error", {"timestamp": 1900, "exception_message": "boom"}]
|
||||
])));
|
||||
assert_eq!(e.run_ms, Some(900));
|
||||
assert_eq!(e.error.as_deref(), Some("boom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_ms_is_none_without_a_start_or_end_or_when_negative() {
|
||||
let no_start = parse_history_entry(&entry(json!([
|
||||
["execution_success", {"timestamp": 8500}]
|
||||
])));
|
||||
assert_eq!(no_start.run_ms, None);
|
||||
let no_end = parse_history_entry(&entry(json!([
|
||||
["execution_start", {"timestamp": 1000}]
|
||||
])));
|
||||
assert_eq!(no_end.run_ms, None);
|
||||
let negative = parse_history_entry(&entry(json!([
|
||||
["execution_start", {"timestamp": 9000}],
|
||||
["execution_success", {"timestamp": 8500}]
|
||||
])));
|
||||
assert_eq!(negative.run_ms, None);
|
||||
}
|
||||
}
|
||||
+21
-120
@@ -3,7 +3,6 @@
|
||||
//!
|
||||
//! - `GET /enhance/templates`
|
||||
//! - `POST /enhance`
|
||||
//! - `GET /enhance/prompts?templates=&limit=`
|
||||
//! - `GET /enhance/jobs?path=`
|
||||
//! - `GET /enhance/jobs/{id}`
|
||||
//! - `GET /enhance/jobs/{id}/preview/{n}`
|
||||
@@ -24,34 +23,27 @@ use super::service::{EnhanceError, EnhanceService, SourceFile};
|
||||
use crate::data::Claims;
|
||||
use crate::database::ExifDao;
|
||||
use crate::database::enhance_dao::{EnhanceJobRow, STATUS_APPROVED, STATUS_READY};
|
||||
use crate::error::ApiError;
|
||||
use crate::libraries::{self, Library};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::normalize_path;
|
||||
use crate::versions::handlers::build_response;
|
||||
use actix_web::ResponseError;
|
||||
|
||||
type ExifData = web::Data<Mutex<Box<dyn ExifDao>>>;
|
||||
|
||||
/// Enhance errors in the shared format (#202). The codes predate it and stay
|
||||
/// as they are: clients may already branch on them.
|
||||
fn error_response(e: &EnhanceError) -> HttpResponse {
|
||||
use actix_web::http::StatusCode;
|
||||
let (status, code) = match e {
|
||||
EnhanceError::Disabled => (StatusCode::SERVICE_UNAVAILABLE, "disabled"),
|
||||
EnhanceError::Unavailable => (StatusCode::SERVICE_UNAVAILABLE, "unavailable"),
|
||||
EnhanceError::UnknownTemplate(_) => (StatusCode::NOT_FOUND, "unknown_template"),
|
||||
EnhanceError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||
EnhanceError::Rejected(_) => (StatusCode::UNPROCESSABLE_ENTITY, "rejected"),
|
||||
EnhanceError::Expired => (StatusCode::GONE, "expired"),
|
||||
EnhanceError::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||
EnhanceError::Unplaceable(_) => (StatusCode::BAD_GATEWAY, "unplaceable"),
|
||||
EnhanceError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream"),
|
||||
EnhanceError::Internal(_) => {
|
||||
return ApiError::internal(e).error_response();
|
||||
}
|
||||
let (mut builder, code) = match e {
|
||||
EnhanceError::Disabled => (HttpResponse::ServiceUnavailable(), "disabled"),
|
||||
EnhanceError::Unavailable => (HttpResponse::ServiceUnavailable(), "unavailable"),
|
||||
EnhanceError::UnknownTemplate(_) => (HttpResponse::NotFound(), "unknown_template"),
|
||||
EnhanceError::BadRequest(_) => (HttpResponse::BadRequest(), "bad_request"),
|
||||
EnhanceError::Rejected(_) => (HttpResponse::UnprocessableEntity(), "rejected"),
|
||||
EnhanceError::Expired => (HttpResponse::Gone(), "expired"),
|
||||
EnhanceError::NotFound => (HttpResponse::NotFound(), "not_found"),
|
||||
EnhanceError::Unplaceable(_) => (HttpResponse::BadGateway(), "unplaceable"),
|
||||
EnhanceError::Upstream(_) => (HttpResponse::BadGateway(), "upstream"),
|
||||
EnhanceError::Internal(_) => (HttpResponse::InternalServerError(), "internal"),
|
||||
};
|
||||
ApiError::new(status, code, e.message()).error_response()
|
||||
builder.json(json!({ "error": e.message(), "code": code }))
|
||||
}
|
||||
|
||||
fn bad(msg: &str) -> HttpResponse {
|
||||
@@ -93,8 +85,6 @@ pub struct JobView {
|
||||
reference_path: Option<String>,
|
||||
created_at: i64,
|
||||
updated_at: i64,
|
||||
/// Milliseconds ComfyUI ran the job; null until it has finished.
|
||||
run_ms: Option<i64>,
|
||||
pending: Vec<PendingView>,
|
||||
saved: Vec<SavedView>,
|
||||
}
|
||||
@@ -128,7 +118,6 @@ pub fn job_view(job: &EnhanceJobRow) -> JobView {
|
||||
reference_path: job.reference_path.clone(),
|
||||
created_at: job.created_at,
|
||||
updated_at: job.updated_at,
|
||||
run_ms: job.run_ms,
|
||||
pending,
|
||||
saved: saved
|
||||
.into_iter()
|
||||
@@ -221,8 +210,6 @@ struct TemplateView {
|
||||
/// Approximate output size; clients warn when the original is larger.
|
||||
output_megapixels: f64,
|
||||
warn_above_megapixels: f64,
|
||||
/// `image` (a PNG version) or `video` (an MP4 clip, e.g. Animate).
|
||||
output: super::template::OutputKind,
|
||||
}
|
||||
|
||||
#[get("/enhance/templates")]
|
||||
@@ -250,7 +237,6 @@ pub async fn get_templates(_: Claims, svc: web::Data<EnhanceService>) -> impl Re
|
||||
default: default.as_deref() == Some(t.cfg.name.as_str()),
|
||||
output_megapixels: t.cfg.output_megapixels,
|
||||
warn_above_megapixels: svc.config.warn_above_megapixels,
|
||||
output: t.output,
|
||||
})
|
||||
.collect();
|
||||
let mut body = json!({
|
||||
@@ -347,20 +333,12 @@ pub async fn get_job(
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct JobsQuery {
|
||||
/// A photo (original or version): that photo's jobs. Omitted: every
|
||||
/// photo's open jobs, for the jobs manager.
|
||||
pub path: Option<String>,
|
||||
pub path: String,
|
||||
pub library: Option<String>,
|
||||
/// Comma-separated statuses to keep (e.g. `queued,running,ready`);
|
||||
/// omitted = all. Applies to both forms.
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// With `path`: a photo's recent jobs, newest first, so the viewer can pick up
|
||||
/// an enhance in progress after reopening. Without: the jobs manager's list,
|
||||
/// every photo's queued / running / unavailable jobs plus ready / failed /
|
||||
/// expired ones from the last few days. Discarded and approved-and-done jobs
|
||||
/// are not listed. DB only.
|
||||
/// A photo's recent jobs, newest first, so the viewer can pick up an enhance
|
||||
/// in progress after reopening. Discarded jobs are not listed.
|
||||
#[get("/enhance/jobs")]
|
||||
pub async fn list_jobs(
|
||||
_: Claims,
|
||||
@@ -369,90 +347,19 @@ pub async fn list_jobs(
|
||||
app_state: web::Data<AppState>,
|
||||
exif_dao: ExifData,
|
||||
) -> impl Responder {
|
||||
let jobs = match q.path.as_deref() {
|
||||
Some(path) => {
|
||||
let file = match resolve_file(&app_state, &exif_dao, path, q.library.as_deref()) {
|
||||
Ok(f) => f,
|
||||
Err(r) => return r,
|
||||
};
|
||||
svc.jobs_for_hash(&file.hash)
|
||||
}
|
||||
None => svc.open_jobs(),
|
||||
let file = match resolve_file(&app_state, &exif_dao, &q.path, q.library.as_deref()) {
|
||||
Ok(f) => f,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let wanted: Option<Vec<&str>> = q.status.as_deref().map(|s| {
|
||||
s.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
});
|
||||
match jobs {
|
||||
match svc.jobs_for_hash(&file.hash) {
|
||||
Ok(jobs) => {
|
||||
let views: Vec<JobView> = jobs
|
||||
.iter()
|
||||
.filter(|j| {
|
||||
wanted
|
||||
.as_ref()
|
||||
.is_none_or(|w| w.contains(&j.status.as_str()))
|
||||
})
|
||||
.map(job_view)
|
||||
.collect();
|
||||
let views: Vec<JobView> = jobs.iter().map(job_view).collect();
|
||||
HttpResponse::Ok().json(json!({ "jobs": views }))
|
||||
}
|
||||
Err(e) => error_response(&e),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PromptsQuery {
|
||||
/// Comma-separated template names; omitted = all templates.
|
||||
pub templates: Option<String>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PromptView {
|
||||
prompt: String,
|
||||
template: String,
|
||||
last_used: i64,
|
||||
uses: i64,
|
||||
}
|
||||
|
||||
/// Prompts used before, newest first, so the app can offer them again for a
|
||||
/// series of similar photos. DB only: not library-scoped, and answered even
|
||||
/// when ComfyUI is down or Enhance is off (then simply from old rows).
|
||||
#[get("/enhance/prompts")]
|
||||
pub async fn recent_prompts(
|
||||
_: Claims,
|
||||
q: web::Query<PromptsQuery>,
|
||||
svc: web::Data<EnhanceService>,
|
||||
) -> impl Responder {
|
||||
let names: Option<Vec<String>> = q.templates.as_deref().map(|t| {
|
||||
t.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
});
|
||||
// `templates=` with nothing in it means "no filter", like omitting it.
|
||||
let names = names.filter(|n| !n.is_empty());
|
||||
let limit = q.limit.unwrap_or(10).clamp(1, 50);
|
||||
match svc.recent_prompts(names.as_deref(), limit) {
|
||||
Ok(rows) => {
|
||||
let prompts: Vec<PromptView> = rows
|
||||
.into_iter()
|
||||
.map(|r| PromptView {
|
||||
prompt: r.prompt,
|
||||
template: r.template,
|
||||
last_used: r.last_used,
|
||||
uses: r.uses,
|
||||
})
|
||||
.collect();
|
||||
HttpResponse::Ok().json(json!({ "prompts": prompts }))
|
||||
}
|
||||
Err(e) => error_response(&e),
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/enhance/jobs/{id}/preview/{n}")]
|
||||
pub async fn preview(
|
||||
_: Claims,
|
||||
@@ -569,13 +476,7 @@ pub async fn approve(
|
||||
};
|
||||
build_response(dao.as_mut(), &original, job.library_id)
|
||||
.ok()
|
||||
// An approved clip (Animate) is listed under `clips`.
|
||||
.and_then(|r| {
|
||||
r.versions
|
||||
.into_iter()
|
||||
.chain(r.clips)
|
||||
.find(|v| v.hash == hash)
|
||||
})
|
||||
.and_then(|r| r.versions.into_iter().find(|v| v.hash == hash))
|
||||
};
|
||||
match entry {
|
||||
Some(v) => HttpResponse::Ok().json(v),
|
||||
|
||||
@@ -263,7 +263,6 @@ impl Fx {
|
||||
.service(super::get_templates)
|
||||
.service(super::submit)
|
||||
.service(super::list_jobs)
|
||||
.service(super::recent_prompts)
|
||||
.service(super::get_job)
|
||||
.service(super::preview)
|
||||
.service(super::approve)
|
||||
@@ -570,8 +569,6 @@ async fn job_moves_queued_running_ready_and_the_preview_is_relayed() {
|
||||
fx.svc.poll_once().await;
|
||||
let (_, j, _) = call(&fx, "GET", &format!("/enhance/jobs/{id}"), None).await;
|
||||
assert_eq!(j["status"], "ready");
|
||||
// finished_entry runs 3000 ms before its end timestamp.
|
||||
assert_eq!(j["run_ms"], 3000);
|
||||
assert!(j.get("queue_position").is_none());
|
||||
assert_eq!(j["pending"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(j["pending"][0]["n"], 0);
|
||||
@@ -583,8 +580,6 @@ async fn job_moves_queued_running_ready_and_the_preview_is_relayed() {
|
||||
let (st, _, bytes) = call(&fx, "GET", &uri, None).await;
|
||||
assert_eq!(st, 200);
|
||||
assert_eq!(bytes, b"PNG:imageapi_pending_abc.png");
|
||||
let (_, list0, _) = call(&fx, "GET", &format!("/enhance/jobs?path={PHOTO}"), None).await;
|
||||
assert_eq!(list0["jobs"][0]["run_ms"], 3000, "list endpoint carries it");
|
||||
let (st, _, _) = call(&fx, "GET", &format!("/enhance/jobs/{id}/preview/5"), None).await;
|
||||
assert_eq!(st, 404);
|
||||
|
||||
@@ -621,9 +616,6 @@ async fn failed_run_reports_comfys_message() {
|
||||
.await;
|
||||
assert_eq!(j["status"], "failed");
|
||||
assert_eq!(j["error"], "CUDA out of memory");
|
||||
// No execution_start in that history entry: the field is present but null.
|
||||
assert!(j.as_object().unwrap().contains_key("run_ms"));
|
||||
assert!(j["run_ms"].is_null());
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
@@ -914,56 +906,6 @@ async fn discard_hides_the_job_from_the_photo_list_only() {
|
||||
assert_eq!(st, 404);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn jobs_without_a_path_list_every_photos_open_jobs() {
|
||||
let fx = fx().await;
|
||||
const OTHER: &str = "2009_200/other.JPG";
|
||||
fx.put(PHOTO, b"photo");
|
||||
fx.put(OTHER, b"other photo");
|
||||
let submit = |path: &'static str| {
|
||||
let fx = &fx;
|
||||
async move {
|
||||
let (_, j, _) = call(
|
||||
fx,
|
||||
"POST",
|
||||
"/enhance",
|
||||
Some(json!({"path": path, "template": "restore"})),
|
||||
)
|
||||
.await;
|
||||
j["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
};
|
||||
let a = submit(PHOTO).await;
|
||||
let b = submit(OTHER).await;
|
||||
let c = submit(OTHER).await;
|
||||
call(&fx, "POST", &format!("/enhance/jobs/{a}/discard"), None).await;
|
||||
|
||||
let ids = |v: &Value| -> Vec<String> {
|
||||
v["jobs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|j| j["id"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
};
|
||||
let (st, all, _) = call(&fx, "GET", "/enhance/jobs", None).await;
|
||||
assert_eq!(st, 200);
|
||||
assert_eq!(
|
||||
ids(&all),
|
||||
[c.clone(), b.clone()],
|
||||
"across photos, newest first, discarded hidden"
|
||||
);
|
||||
assert_eq!(all["jobs"][0]["path"], OTHER, "each job names its photo");
|
||||
|
||||
let (_, none, _) = call(&fx, "GET", "/enhance/jobs?status=ready,failed", None).await;
|
||||
assert!(
|
||||
ids(&none).is_empty(),
|
||||
"status filter keeps only those statuses"
|
||||
);
|
||||
let (_, queued, _) = call(&fx, "GET", "/enhance/jobs?status=queued,%20running", None).await;
|
||||
assert_eq!(ids(&queued).len(), 2);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn unreachable_active_jobs_become_unavailable_after_the_grace_period() {
|
||||
// Submit against the mock, then point nothing at it: use a service whose
|
||||
@@ -991,7 +933,6 @@ async fn unreachable_active_jobs_become_unavailable_after_the_grace_period() {
|
||||
discarded: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
run_ms: None,
|
||||
};
|
||||
SqliteEnhanceJobDao::from_shared(fx.conn.clone())
|
||||
.insert(&dao_job)
|
||||
@@ -1042,62 +983,3 @@ async fn busy_queue_blocks_the_free() {
|
||||
fx.svc.idle_check().await;
|
||||
assert_eq!(fx.mock.free_calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn recent_prompts_lists_distinct_trimmed_prompts_even_when_discarded() {
|
||||
let fx = fx().await;
|
||||
fx.put(PHOTO, b"photo");
|
||||
let mut ids = Vec::new();
|
||||
for prompt in ["warmer", " warmer ", "less grain"] {
|
||||
let (st, job, _) = call(
|
||||
&fx,
|
||||
"POST",
|
||||
"/enhance",
|
||||
Some(json!({"path": PHOTO, "template": "restore", "prompt": prompt})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, 200);
|
||||
ids.push(job["id"].as_str().unwrap().to_string());
|
||||
}
|
||||
call(
|
||||
&fx,
|
||||
"POST",
|
||||
&format!("/enhance/jobs/{}/discard", ids[0]),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (st, body, _) = call(&fx, "GET", "/enhance/prompts", None).await;
|
||||
assert_eq!(st, 200);
|
||||
let rows = body["prompts"].as_array().unwrap();
|
||||
let texts: Vec<(&str, i64)> = rows
|
||||
.iter()
|
||||
.map(|r| (r["prompt"].as_str().unwrap(), r["uses"].as_i64().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(texts.iter().map(|t| t.1).sum::<i64>(), 3);
|
||||
assert!(texts.contains(&("warmer", 2)) && texts.contains(&("less grain", 1)));
|
||||
assert_eq!(rows[0]["template"], "restore");
|
||||
assert!(rows[0]["last_used"].as_i64().unwrap() > 0);
|
||||
|
||||
let (_, body, _) = call(&fx, "GET", "/enhance/prompts?templates=nope", None).await;
|
||||
assert_eq!(body, json!({"prompts": []}));
|
||||
let (_, body, _) = call(
|
||||
&fx,
|
||||
"GET",
|
||||
"/enhance/prompts?templates=nope,restore&limit=0",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
body["prompts"].as_array().unwrap().len(),
|
||||
1,
|
||||
"limit clamps to 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn recent_prompts_answers_when_enhance_is_off() {
|
||||
let fx = fx_with(None, |c| c.url = None).await;
|
||||
let (st, body, _) = call(&fx, "GET", "/enhance/prompts", None).await;
|
||||
assert_eq!((st, body), (200, json!({"prompts": []})));
|
||||
}
|
||||
+1
-3
@@ -21,7 +21,5 @@ pub mod template;
|
||||
#[cfg(test)]
|
||||
mod integration_tests;
|
||||
|
||||
pub use handlers::{
|
||||
approve, discard, get_job, get_templates, list_jobs, preview, recent_prompts, submit,
|
||||
};
|
||||
pub use handlers::{approve, discard, get_job, get_templates, list_jobs, preview, submit};
|
||||
pub use service::EnhanceService;
|
||||
+6
-36
@@ -16,10 +16,10 @@ use serde::{Deserialize, Serialize};
|
||||
use super::comfy::{ApproveError, ComfyClient, ComfyError, HistoryEntry, Staged, SubmitError};
|
||||
use super::config::{EnhanceConfig, Loaded, PromptMode};
|
||||
use super::path_map::PathMap;
|
||||
use super::template::{Template, random_seed};
|
||||
use super::template::Template;
|
||||
use crate::database::enhance_dao::{
|
||||
EnhanceJobDao, EnhanceJobRow, JobUpdate, RecentPrompt, STATUS_APPROVED, STATUS_EXPIRED,
|
||||
STATUS_FAILED, STATUS_QUEUED, STATUS_READY, STATUS_RUNNING, STATUS_UNAVAILABLE,
|
||||
EnhanceJobDao, EnhanceJobRow, JobUpdate, STATUS_APPROVED, STATUS_EXPIRED, STATUS_FAILED,
|
||||
STATUS_QUEUED, STATUS_READY, STATUS_RUNNING, STATUS_UNAVAILABLE,
|
||||
};
|
||||
|
||||
/// Short timeout for the `available` probe, so the templates call stays snappy
|
||||
@@ -31,11 +31,6 @@ const IDLE_CHECK_EVERY: Duration = Duration::from_secs(30);
|
||||
const JOB_RETENTION_SECS: i64 = 14 * 24 * 3600;
|
||||
/// Jobs shown per photo.
|
||||
pub const JOBS_PER_PHOTO: i64 = 20;
|
||||
/// The jobs manager lists finished-but-unhandled jobs (ready, failed,
|
||||
/// expired) for this long; active ones always.
|
||||
pub const OPEN_JOBS_WINDOW_SECS: i64 = 3 * 24 * 3600;
|
||||
/// Most jobs the jobs manager lists.
|
||||
pub const OPEN_JOBS_LIMIT: i64 = 100;
|
||||
|
||||
pub const MSG_UNAVAILABLE: &str = "ComfyUI isn't running";
|
||||
pub const MSG_EXPIRED: &str = "This result expired (ComfyUI was restarted). Run it again.";
|
||||
@@ -319,7 +314,9 @@ impl EnhanceService {
|
||||
None => None,
|
||||
};
|
||||
let filled = template
|
||||
.fill(&source, prompt, reference_path.as_deref(), &mut random_seed)
|
||||
.fill(&source, prompt, reference_path.as_deref(), &mut || {
|
||||
rand::random::<u64>() >> 12
|
||||
})
|
||||
.map_err(|e| EnhanceError::BadRequest(e.0))?;
|
||||
|
||||
if !self.probe_now().await {
|
||||
@@ -364,7 +361,6 @@ impl EnhanceService {
|
||||
discarded: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
run_ms: None,
|
||||
};
|
||||
self.dao
|
||||
.lock()
|
||||
@@ -395,30 +391,6 @@ impl EnhanceService {
|
||||
.map_err(|e| EnhanceError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Every photo's open jobs for the jobs manager, newest first. DB only:
|
||||
/// the poller keeps the rows current, so this never calls ComfyUI.
|
||||
pub fn open_jobs(&self) -> Result<Vec<EnhanceJobRow>, EnhanceError> {
|
||||
let since = chrono::Utc::now().timestamp() - OPEN_JOBS_WINDOW_SECS;
|
||||
self.dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.list_open(since, OPEN_JOBS_LIMIT)
|
||||
.map_err(|e| EnhanceError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Distinct recent prompts; DB only, so it works with ComfyUI down.
|
||||
pub fn recent_prompts(
|
||||
&self,
|
||||
templates: Option<&[String]>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<RecentPrompt>, EnhanceError> {
|
||||
self.dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.recent_prompts(templates, limit)
|
||||
.map_err(|e| EnhanceError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Hide a job from the per-photo list. Nothing is deleted on ComfyUI:
|
||||
/// staged results vanish on its restart anyway.
|
||||
pub fn discard(&self, id: &str) -> Result<EnhanceJobRow, EnhanceError> {
|
||||
@@ -548,7 +520,6 @@ impl EnhanceService {
|
||||
.error
|
||||
.unwrap_or_else(|| "ComfyUI reported an error".to_string()),
|
||||
);
|
||||
u.run_ms = entry.run_ms;
|
||||
self.update_job(&job.id, &u);
|
||||
return;
|
||||
}
|
||||
@@ -560,7 +531,6 @@ impl EnhanceService {
|
||||
}
|
||||
let mut u = JobUpdate::status(STATUS_READY);
|
||||
u.pending = serde_json::to_string(&entry.staged).ok();
|
||||
u.run_ms = entry.run_ms;
|
||||
self.update_job(&job.id, &u);
|
||||
info!(
|
||||
"enhance: job {} ready ({} result(s))",
|
||||
|
||||
+1
-56
@@ -19,19 +19,6 @@ pub const TITLE_REFERENCE: &str = "imageapi:reference";
|
||||
/// Prompt-node inputs, in the order they are tried.
|
||||
const PROMPT_INPUTS: &[&str] = &["text", "prompt", "value"];
|
||||
|
||||
/// What a template produces, from its `imageapi:save` node's class.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OutputKind {
|
||||
/// Save Image Version: PNG versions.
|
||||
Image,
|
||||
/// Save Video Version: an MP4 (e.g. a clip animated from the photo).
|
||||
Video,
|
||||
}
|
||||
|
||||
/// The save node class that makes a template's output a video.
|
||||
pub const VIDEO_SAVE_CLASS: &str = "ImageApiSaveVideoVersion";
|
||||
|
||||
/// Output nodes that write into ComfyUI's output folder on every API run.
|
||||
const STRAY_OUTPUT_CLASSES: &[&str] = &[
|
||||
"SaveImage",
|
||||
@@ -51,8 +38,6 @@ pub struct Template {
|
||||
/// `(node id, input name)` of the prompt text.
|
||||
prompt: Option<(String, &'static str)>,
|
||||
reference_id: Option<String>,
|
||||
/// Image or video, from the save node's class.
|
||||
pub output: OutputKind,
|
||||
/// Startup warnings (stray output nodes).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -158,11 +143,6 @@ impl Template {
|
||||
));
|
||||
}
|
||||
}
|
||||
let output = if workflow[&save_id]["class_type"].as_str() == Some(VIDEO_SAVE_CLASS) {
|
||||
OutputKind::Video
|
||||
} else {
|
||||
OutputKind::Image
|
||||
};
|
||||
Ok(Template {
|
||||
cfg,
|
||||
workflow,
|
||||
@@ -170,7 +150,6 @@ impl Template {
|
||||
save_id,
|
||||
prompt,
|
||||
reference_id,
|
||||
output,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
@@ -306,13 +285,6 @@ fn links_to(v: &Value, id: &str) -> bool {
|
||||
matches!(v.as_array().map(Vec::as_slice), Some([Value::String(s), Value::Number(_)]) if s == id)
|
||||
}
|
||||
|
||||
/// A fresh seed for one job. 32 bits: some nodes cap their seed there
|
||||
/// (SeedVR2VideoUpscaler's max is 2^32 - 1), and it's plenty of variety for
|
||||
/// the ones that take 64.
|
||||
pub fn random_seed() -> u64 {
|
||||
u64::from(rand::random::<u32>())
|
||||
}
|
||||
|
||||
fn is_seed_input(key: &str) -> bool {
|
||||
key == "seed" || key == "noise_seed" || key.ends_with(".seed")
|
||||
}
|
||||
@@ -429,39 +401,12 @@ mod tests {
|
||||
assert!(a > 1000 && b > 1000 && a != b);
|
||||
assert_ne!(a, 774744764437710);
|
||||
// Two fills with a real RNG differ.
|
||||
let mut rng = random_seed;
|
||||
let mut rng = || rand::random::<u64>() >> 12;
|
||||
let x = t.fill("p", None, None, &mut rng).unwrap();
|
||||
let y = t.fill("p", None, None, &mut rng).unwrap();
|
||||
assert_ne!(input(&x.prompt, "5", "seed"), input(&y.prompt, "5", "seed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_kind_follows_the_save_node() {
|
||||
assert_eq!(
|
||||
load(RESTORE, PromptMode::Append).unwrap().output,
|
||||
OutputKind::Image
|
||||
);
|
||||
let video = RESTORE.replace(
|
||||
"\"ImageApiSaveImageVersion\"",
|
||||
"\"ImageApiSaveVideoVersion\"",
|
||||
);
|
||||
assert_ne!(video, RESTORE);
|
||||
assert_eq!(
|
||||
load(&video, PromptMode::Append).unwrap().output,
|
||||
OutputKind::Video
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_seeds_fit_seedvr2s_u32_limit() {
|
||||
// SeedVR2VideoUpscaler rejects seeds above 2^32 - 1 at validation.
|
||||
let t = load(RESTORE, PromptMode::Append).unwrap();
|
||||
for _ in 0..200 {
|
||||
let f = t.fill("p", None, None, &mut random_seed).unwrap();
|
||||
assert!(input(&f.prompt, "5", "seed").as_u64().unwrap() <= u64::from(u32::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_approval_even_if_the_export_had_it_off() {
|
||||
let off = RESTORE.replace("\"require_approval\": true", "\"require_approval\": false");
|
||||
|
||||
+6
-425
@@ -1,433 +1,14 @@
|
||||
//! The one error response format (#202):
|
||||
//!
|
||||
//! ```json
|
||||
//! { "error": "Tag name must not be empty", "code": "invalid_tag_name", "details": { } }
|
||||
//! ```
|
||||
//!
|
||||
//! - `error`: English, safe to show a user as is. Clients may translate known
|
||||
//! codes into their own wording.
|
||||
//! - `code`: a stable snake_case id for clients to branch on. Never rename one
|
||||
//! a client may depend on; add a new one instead.
|
||||
//! - `details` (optional): structured extras for the client.
|
||||
//!
|
||||
//! Handlers return `Result<_, ApiError>`. An unexpected failure (`anyhow`,
|
||||
//! diesel, I/O) converts into a 500 `internal` whose detail is logged and
|
||||
//! never sent: error text can hold SQL and library paths.
|
||||
use actix_web::{error::InternalError, http::StatusCode};
|
||||
|
||||
use actix_web::error::{JsonPayloadError, PathError, QueryPayloadError};
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{HttpRequest, HttpResponse, ResponseError, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// The JSON body of every error response.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ErrorBody {
|
||||
pub error: String,
|
||||
pub code: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// An error response. Build one with a status constructor, e.g.
|
||||
/// `ApiError::bad_request("invalid_tag_name", "Tag name must not be empty")`.
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
code: &'static str,
|
||||
message: String,
|
||||
details: Option<serde_json::Value>,
|
||||
/// Fields merged into the top level of the body, for responses that
|
||||
/// carried them before this format existed. New code uses `details`.
|
||||
legacy: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
/// What a 500 says to the client; the real cause goes to the log.
|
||||
const INTERNAL_MESSAGE: &str = "Something went wrong on the server";
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
|
||||
ApiError {
|
||||
status,
|
||||
code,
|
||||
message: message.into(),
|
||||
details: None,
|
||||
legacy: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bad_request(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, code, message)
|
||||
}
|
||||
|
||||
pub fn unauthorized(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::UNAUTHORIZED, code, message)
|
||||
}
|
||||
|
||||
pub fn not_found(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::NOT_FOUND, code, message)
|
||||
}
|
||||
|
||||
pub fn conflict(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::CONFLICT, code, message)
|
||||
}
|
||||
|
||||
pub fn forbidden(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::FORBIDDEN, code, message)
|
||||
}
|
||||
|
||||
pub fn gone(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::GONE, code, message)
|
||||
}
|
||||
|
||||
pub fn too_many_requests(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::TOO_MANY_REQUESTS, code, message)
|
||||
}
|
||||
|
||||
/// 502: a service this server depends on (Apollo, ComfyUI, an LLM, SMS-API)
|
||||
/// failed or answered badly.
|
||||
pub fn bad_gateway(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::BAD_GATEWAY, code, message)
|
||||
}
|
||||
|
||||
/// 503: a feature that isn't configured, or a dependency that's down.
|
||||
pub fn unavailable(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::SERVICE_UNAVAILABLE, code, message)
|
||||
}
|
||||
|
||||
/// A 500 for an unexpected failure. `cause` is logged, never sent.
|
||||
pub fn internal(cause: impl fmt::Debug) -> Self {
|
||||
log::error!("Internal error: {:?}", cause);
|
||||
Self::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
INTERNAL_MESSAGE,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_details(mut self, details: impl Serialize) -> Self {
|
||||
self.details = serde_json::to_value(details).ok();
|
||||
self
|
||||
}
|
||||
|
||||
/// Also put `key` at the top level of the body, where a client written
|
||||
/// before this format reads it. The value is in `details` too.
|
||||
pub fn with_legacy_field(mut self, key: &str, value: impl Serialize) -> Self {
|
||||
let value = serde_json::to_value(value).unwrap_or_default();
|
||||
let details = self
|
||||
.details
|
||||
.get_or_insert_with(|| serde_json::Value::Object(Default::default()));
|
||||
if let Some(map) = details.as_object_mut() {
|
||||
map.insert(key.to_string(), value.clone());
|
||||
}
|
||||
self.legacy
|
||||
.get_or_insert_with(Default::default)
|
||||
.insert(key.to_string(), value);
|
||||
self
|
||||
}
|
||||
|
||||
/// The response, for handlers that return `HttpResponse` rather than
|
||||
/// `ApiResult`.
|
||||
pub fn into_response(self) -> HttpResponse {
|
||||
self.error_response()
|
||||
}
|
||||
|
||||
pub fn code(&self) -> &'static str {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn body(&self) -> ErrorBody {
|
||||
ErrorBody {
|
||||
error: self.message.clone(),
|
||||
code: self.code.to_string(),
|
||||
details: self.details.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} {}: {}",
|
||||
self.status.as_u16(),
|
||||
self.code,
|
||||
self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for ApiError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
self.status
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let mut body = serde_json::to_value(self.body()).unwrap_or_default();
|
||||
if let (Some(legacy), Some(map)) = (&self.legacy, body.as_object_mut()) {
|
||||
for (k, v) in legacy {
|
||||
map.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
}
|
||||
HttpResponse::build(self.status).json(body)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for ApiError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
ApiError::internal(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for ApiError {
|
||||
fn from(e: diesel::result::Error) -> Self {
|
||||
ApiError::internal(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an `anyhow` result into an [`ApiResult`]: an error becomes a 500
|
||||
/// `internal` (logged, not sent). Prefer `?` with an `ApiResult` return type
|
||||
/// in new handlers.
|
||||
pub trait IntoHttpError<T> {
|
||||
fn into_http_internal_err(self) -> ApiResult<T>;
|
||||
fn into_http_internal_err(self) -> Result<T, actix_web::Error>;
|
||||
}
|
||||
|
||||
impl<T> IntoHttpError<T> for Result<T, anyhow::Error> {
|
||||
fn into_http_internal_err(self) -> ApiResult<T> {
|
||||
self.map_err(ApiError::internal)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- request-parsing failures ------------------------------------------
|
||||
//
|
||||
// actix answers these itself in plain text unless an extractor config says
|
||||
// otherwise. Register all three on the App (see `main.rs`).
|
||||
|
||||
pub fn json_config() -> web::JsonConfig {
|
||||
web::JsonConfig::default().error_handler(|err: JsonPayloadError, req: &HttpRequest| {
|
||||
log::warn!("JSON body error on {} {}: {}", req.method(), req.uri(), err);
|
||||
let api = match &err {
|
||||
JsonPayloadError::OverflowKnownLength { .. } | JsonPayloadError::Overflow { .. } => {
|
||||
ApiError::new(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"body_too_large",
|
||||
"The request is too large",
|
||||
)
|
||||
}
|
||||
JsonPayloadError::ContentType => ApiError::bad_request(
|
||||
"invalid_body",
|
||||
"The request body must be JSON (Content-Type: application/json)",
|
||||
),
|
||||
// serde's message names the field at fault, e.g. "missing field
|
||||
// `file_names`"; it describes the request, not the server.
|
||||
_ => ApiError::bad_request("invalid_body", format!("Invalid request body: {}", err)),
|
||||
};
|
||||
actix_web::error::InternalError::from_response(err, api.error_response()).into()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn query_config() -> web::QueryConfig {
|
||||
web::QueryConfig::default().error_handler(|err: QueryPayloadError, req: &HttpRequest| {
|
||||
log::warn!("Query error on {} {}: {}", req.method(), req.uri(), err);
|
||||
let api = ApiError::bad_request("invalid_query", format!("Invalid query: {}", err));
|
||||
actix_web::error::InternalError::from_response(err, api.error_response()).into()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn path_config() -> web::PathConfig {
|
||||
web::PathConfig::default().error_handler(|err: PathError, req: &HttpRequest| {
|
||||
log::warn!("Path error on {} {}: {}", req.method(), req.uri(), err);
|
||||
let api = ApiError::bad_request("invalid_path", format!("Invalid URL: {}", err));
|
||||
actix_web::error::InternalError::from_response(err, api.error_response()).into()
|
||||
})
|
||||
}
|
||||
|
||||
/// Multipart upload failures (malformed form, over the size limit).
|
||||
pub fn multipart_config(total_limit: usize) -> actix_multipart::form::MultipartFormConfig {
|
||||
actix_multipart::form::MultipartFormConfig::default()
|
||||
.total_limit(total_limit)
|
||||
.error_handler(|err, req: &HttpRequest| {
|
||||
log::warn!("Upload error on {} {}: {}", req.method(), req.uri(), err);
|
||||
let api = if err.to_string().contains("limit") {
|
||||
ApiError::new(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"body_too_large",
|
||||
"The upload is too large",
|
||||
)
|
||||
} else {
|
||||
ApiError::bad_request("invalid_upload", format!("Invalid upload: {}", err))
|
||||
};
|
||||
actix_web::error::InternalError::from_response(err, api.error_response()).into()
|
||||
fn into_http_internal_err(self) -> Result<T, actix_web::Error> {
|
||||
self.map_err(|e| {
|
||||
log::error!("Map to err: {:?}", e);
|
||||
InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR).into()
|
||||
})
|
||||
}
|
||||
|
||||
/// The App's default service: an unknown route answers in the same format.
|
||||
pub async fn unknown_route() -> HttpResponse {
|
||||
ApiError::not_found("unknown_route", "No such endpoint").error_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use actix_web::body::MessageBody;
|
||||
use actix_web::{App, test as awtest};
|
||||
|
||||
fn body_of(resp: HttpResponse) -> serde_json::Value {
|
||||
let bytes = resp.into_body().try_into_bytes().unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_error_and_code() {
|
||||
let resp = ApiError::bad_request("invalid_tag_name", "Tag name must not be empty")
|
||||
.error_response();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
body_of(resp),
|
||||
serde_json::json!({ "error": "Tag name must not be empty", "code": "invalid_tag_name" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_and_legacy_fields() {
|
||||
let resp = ApiError::conflict("tag_name_taken", "Taken")
|
||||
.with_legacy_field("existing_tag", serde_json::json!({ "id": 3 }))
|
||||
.error_response();
|
||||
assert_eq!(
|
||||
body_of(resp),
|
||||
serde_json::json!({
|
||||
"error": "Taken",
|
||||
"code": "tag_name_taken",
|
||||
"details": { "existing_tag": { "id": 3 } },
|
||||
"existing_tag": { "id": 3 },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_errors_never_send_their_cause() {
|
||||
let cause = anyhow::anyhow!("SELECT * FROM image_exif WHERE rel_path = '/secret/a.jpg'");
|
||||
let resp = ApiError::from(cause).error_response();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body = body_of(resp);
|
||||
assert_eq!(body["code"], "internal");
|
||||
assert!(!body.to_string().contains("secret"), "{body}");
|
||||
}
|
||||
|
||||
/// Every error response goes through `ApiError` (#202). A handler that
|
||||
/// builds one by hand (`HttpResponse::NotFound()...`) skips the format;
|
||||
/// this fails with the file and line so it can be converted.
|
||||
#[test]
|
||||
fn no_handler_builds_an_error_response_by_hand() {
|
||||
const BUILDERS: &[&str] = &[
|
||||
"BadRequest",
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"NotFound",
|
||||
"MethodNotAllowed",
|
||||
"Conflict",
|
||||
"Gone",
|
||||
"PayloadTooLarge",
|
||||
"UnprocessableEntity",
|
||||
"TooManyRequests",
|
||||
"RangeNotSatisfiable",
|
||||
"InternalServerError",
|
||||
"NotImplemented",
|
||||
"BadGateway",
|
||||
"ServiceUnavailable",
|
||||
"GatewayTimeout",
|
||||
];
|
||||
fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
walk(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut files = Vec::new();
|
||||
walk(&src, &mut files);
|
||||
|
||||
let mut offenders = Vec::new();
|
||||
for file in files {
|
||||
let name = file.to_string_lossy();
|
||||
if name.ends_with("tests.rs") || name.ends_with("/error.rs") {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&file).unwrap();
|
||||
// Test modules may assert on hand-built responses.
|
||||
let code = match text.find("#[cfg(test)]\nmod ") {
|
||||
Some(i) => &text[..i],
|
||||
None => &text[..],
|
||||
};
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
if line.trim_start().starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
for b in BUILDERS {
|
||||
if line.contains(&format!("HttpResponse::{b}()")) {
|
||||
offenders.push(format!("{}:{}: {}", name, n + 1, line.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"build these with crate::error::ApiError instead:\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Q {
|
||||
#[allow(dead_code)]
|
||||
n: i32,
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn request_parsing_failures_use_the_format() {
|
||||
let app = awtest::init_service(
|
||||
App::new()
|
||||
.app_data(json_config())
|
||||
.app_data(query_config())
|
||||
.app_data(path_config())
|
||||
.route("/j", web::post().to(|_: web::Json<Q>| async { "ok" }))
|
||||
.route("/q", web::get().to(|_: web::Query<Q>| async { "ok" }))
|
||||
.route("/p/{n}", web::get().to(|_: web::Path<i32>| async { "ok" }))
|
||||
.default_service(web::to(unknown_route)),
|
||||
)
|
||||
.await;
|
||||
|
||||
for (req, status, code) in [
|
||||
(
|
||||
awtest::TestRequest::post()
|
||||
.uri("/j")
|
||||
.set_json(serde_json::json!({})),
|
||||
400,
|
||||
"invalid_body",
|
||||
),
|
||||
(
|
||||
awtest::TestRequest::get().uri("/q?n=x"),
|
||||
400,
|
||||
"invalid_query",
|
||||
),
|
||||
(awtest::TestRequest::get().uri("/p/x"), 400, "invalid_path"),
|
||||
(
|
||||
awtest::TestRequest::get().uri("/nope"),
|
||||
404,
|
||||
"unknown_route",
|
||||
),
|
||||
] {
|
||||
let resp = awtest::call_service(&app, req.to_request()).await;
|
||||
assert_eq!(resp.status().as_u16(), status);
|
||||
let body: ErrorBody = awtest::read_body_json(resp).await;
|
||||
assert_eq!(body.code, code);
|
||||
assert!(!body.error.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-98
@@ -21,7 +21,6 @@
|
||||
use crate::Claims;
|
||||
use crate::ai::face_client::{DetectMeta, FaceClient, FaceDetectError};
|
||||
use crate::database::schema::{face_detections, image_exif, persons};
|
||||
use crate::error::ApiError;
|
||||
use crate::error::IntoHttpError;
|
||||
use crate::exif;
|
||||
use crate::file_types;
|
||||
@@ -1686,11 +1685,11 @@ async fn list_faces_handler<D: FaceDao>(
|
||||
// the watcher catches up.
|
||||
return HttpResponse::Ok().json(Vec::<FaceWithPerson>::new());
|
||||
}
|
||||
Err(e) => return ApiError::internal(e.to_string()).into_response(),
|
||||
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
||||
};
|
||||
match dao.list_for_content_hash(&hash) {
|
||||
Ok(faces) => HttpResponse::Ok().json(faces),
|
||||
Err(e) => ApiError::internal(e.to_string()).into_response(),
|
||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1746,7 +1745,7 @@ async fn create_face_handler<D: FaceDao>(
|
||||
// strict path does. Surface the disabled state only when we'd
|
||||
// actually use the client.
|
||||
if !body.force && !face_client.is_enabled() {
|
||||
return faces_unavailable().into_response();
|
||||
return HttpResponse::ServiceUnavailable().body("face client disabled");
|
||||
}
|
||||
|
||||
let normalized_path = normalize_path(&body.path);
|
||||
@@ -1764,13 +1763,10 @@ async fn create_face_handler<D: FaceDao>(
|
||||
match dao.resolve_content_hash(library.id, &normalized_path) {
|
||||
Ok(Some(h)) => h,
|
||||
Ok(None) => {
|
||||
return ApiError::conflict(
|
||||
"photo_not_indexed",
|
||||
"This photo is still being indexed. Try again in a minute.",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::Conflict()
|
||||
.body("photo not yet hashed; wait for next watcher pass");
|
||||
}
|
||||
Err(e) => return ApiError::internal(e.to_string()).into_response(),
|
||||
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1802,7 +1798,7 @@ async fn create_face_handler<D: FaceDao>(
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("crop_image_to_bbox failed for {:?}: {:?}", abs_path, e);
|
||||
return bad_crop(e).into_response();
|
||||
return HttpResponse::BadRequest().body(format!("cannot crop photo: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1816,14 +1812,13 @@ async fn create_face_handler<D: FaceDao>(
|
||||
let detect = match face_client.embed(crop_bytes, meta).await {
|
||||
Ok(r) => r,
|
||||
Err(FaceDetectError::Permanent(e)) => {
|
||||
return no_face_found(e).into_response();
|
||||
return HttpResponse::UnprocessableEntity().body(format!("{}", e));
|
||||
}
|
||||
Err(FaceDetectError::Transient(e)) => {
|
||||
warn!("manual face: face service transient error: {}", e);
|
||||
return faces_busy().into_response();
|
||||
return HttpResponse::ServiceUnavailable().body(format!("{}", e));
|
||||
}
|
||||
Err(FaceDetectError::Disabled) => {
|
||||
return faces_unavailable().into_response();
|
||||
return HttpResponse::ServiceUnavailable().body("face client disabled");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1831,18 +1826,14 @@ async fn create_face_handler<D: FaceDao>(
|
||||
Some(f) => f.clone(),
|
||||
None => {
|
||||
// Apollo would have returned 422 on no_face_in_crop; defensive.
|
||||
return no_face_found("no face in crop").into_response();
|
||||
return HttpResponse::UnprocessableEntity().body("no face in crop");
|
||||
}
|
||||
};
|
||||
let bytes = match detected.decode_embedding() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("manual face: decode embedding failed: {:?}", e);
|
||||
return ApiError::bad_gateway(
|
||||
"faces_bad_response",
|
||||
"The face service sent an answer this server couldn't read",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::BadGateway().body("invalid embedding from face service");
|
||||
}
|
||||
};
|
||||
(bytes, detect.model_version, detected.confidence)
|
||||
@@ -1866,7 +1857,7 @@ async fn create_face_handler<D: FaceDao>(
|
||||
model_version,
|
||||
}) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return ApiError::internal(e.to_string()).into_response(),
|
||||
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
||||
};
|
||||
info!(
|
||||
"Created manual face id={} library={} hash={} person_id={:?}",
|
||||
@@ -1874,7 +1865,7 @@ async fn create_face_handler<D: FaceDao>(
|
||||
);
|
||||
match hydrate_face_with_person(&mut *dao, row) {
|
||||
Ok(joined) => HttpResponse::Created().json(joined),
|
||||
Err(e) => ApiError::internal(e.to_string()).into_response(),
|
||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1911,17 +1902,18 @@ async fn update_face_handler<D: FaceDao>(
|
||||
Bbox edit requires Apollo to re-embed.",
|
||||
id
|
||||
);
|
||||
return faces_unavailable().into_response();
|
||||
return HttpResponse::ServiceUnavailable()
|
||||
.body("face client disabled — bbox edit requires Apollo");
|
||||
}
|
||||
// Look up the current row so we know which photo to crop.
|
||||
let current = {
|
||||
let mut dao = face_dao.lock().expect("face dao lock");
|
||||
match dao.get_face(id) {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => return face_not_found().into_response(),
|
||||
Ok(None) => return HttpResponse::NotFound().finish(),
|
||||
Err(e) => {
|
||||
warn!("PATCH /image/faces/{}: 500 — get_face failed: {:#}", id, e);
|
||||
return ApiError::internal(e.to_string()).into_response();
|
||||
return HttpResponse::InternalServerError().body(e.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1932,11 +1924,10 @@ async fn update_face_handler<D: FaceDao>(
|
||||
"PATCH /image/faces/{}: 500 — face row references unknown library_id {}",
|
||||
id, current.library_id
|
||||
);
|
||||
return ApiError::internal(format!(
|
||||
return HttpResponse::InternalServerError().body(format!(
|
||||
"face row references unknown library_id {}",
|
||||
current.library_id
|
||||
))
|
||||
.into_response();
|
||||
));
|
||||
}
|
||||
};
|
||||
let abs_path = library.resolve(¤t.rel_path);
|
||||
@@ -1947,7 +1938,7 @@ async fn update_face_handler<D: FaceDao>(
|
||||
"PATCH /image/faces/{}: crop failed for {:?}: {:?}",
|
||||
id, abs_path, e
|
||||
);
|
||||
return bad_crop(e).into_response();
|
||||
return HttpResponse::BadRequest().body(format!("cannot crop new bbox: {}", e));
|
||||
}
|
||||
};
|
||||
let meta = DetectMeta {
|
||||
@@ -1999,7 +1990,7 @@ async fn update_face_handler<D: FaceDao>(
|
||||
error during re-embed: {}",
|
||||
id, e
|
||||
);
|
||||
return faces_busy().into_response();
|
||||
return HttpResponse::ServiceUnavailable().body(format!("{}", e));
|
||||
}
|
||||
Err(FaceDetectError::Disabled) => {
|
||||
warn!(
|
||||
@@ -2007,7 +1998,7 @@ async fn update_face_handler<D: FaceDao>(
|
||||
mid-flight",
|
||||
id
|
||||
);
|
||||
return faces_unavailable().into_response();
|
||||
return HttpResponse::ServiceUnavailable().body("face client disabled mid-flight");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2024,7 +2015,7 @@ async fn update_face_handler<D: FaceDao>(
|
||||
"PATCH /image/faces/{}: 500 — update_face failed: {:#}",
|
||||
id, e
|
||||
);
|
||||
return ApiError::internal(e.to_string()).into_response();
|
||||
return HttpResponse::InternalServerError().body(e.to_string());
|
||||
}
|
||||
};
|
||||
// Hydrate person_name so the response shape matches GET /image/faces
|
||||
@@ -2038,7 +2029,7 @@ async fn update_face_handler<D: FaceDao>(
|
||||
"PATCH /image/faces/{}: 500 — hydrate_face_with_person failed: {:#}",
|
||||
id, e
|
||||
);
|
||||
ApiError::internal(e.to_string()).into_response()
|
||||
HttpResponse::InternalServerError().body(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2052,8 +2043,8 @@ async fn delete_face_handler<D: FaceDao>(
|
||||
let mut dao = face_dao.lock().expect("face dao lock");
|
||||
match dao.delete_face(path.into_inner()) {
|
||||
Ok(true) => HttpResponse::NoContent().finish(),
|
||||
Ok(false) => face_not_found().into_response(),
|
||||
Err(e) => ApiError::internal(e.to_string()).into_response(),
|
||||
Ok(false) => HttpResponse::NotFound().finish(),
|
||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2087,53 +2078,6 @@ async fn ignore_bucket_handler<D: FaceDao>(
|
||||
.into_http_internal_err()
|
||||
}
|
||||
|
||||
// ---- error responses (#202) ---------------------------------------------
|
||||
|
||||
/// Apollo's face service isn't configured.
|
||||
fn faces_unavailable() -> ApiError {
|
||||
ApiError::unavailable(
|
||||
"faces_unavailable",
|
||||
"Face recognition isn't set up on this server",
|
||||
)
|
||||
}
|
||||
|
||||
/// Apollo's face service failed in a way a retry may fix.
|
||||
fn faces_busy() -> ApiError {
|
||||
ApiError::unavailable(
|
||||
"faces_busy",
|
||||
"Face recognition is busy or down. Try again shortly.",
|
||||
)
|
||||
}
|
||||
|
||||
fn no_face_found(reason: impl std::fmt::Display) -> ApiError {
|
||||
ApiError::new(
|
||||
actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"no_face_found",
|
||||
"No face was found in that box",
|
||||
)
|
||||
.with_details(serde_json::json!({ "reason": reason.to_string() }))
|
||||
}
|
||||
|
||||
/// The crop error can name the file's absolute path; the caller logs it.
|
||||
fn bad_crop(_e: impl std::fmt::Display) -> ApiError {
|
||||
ApiError::bad_request("invalid_face_box", "Couldn't crop the photo to that box")
|
||||
}
|
||||
|
||||
fn face_not_found() -> ApiError {
|
||||
ApiError::not_found("face_not_found", "That face no longer exists")
|
||||
}
|
||||
|
||||
fn person_not_found() -> ApiError {
|
||||
ApiError::not_found("person_not_found", "That person no longer exists")
|
||||
}
|
||||
|
||||
fn person_name_taken() -> ApiError {
|
||||
ApiError::conflict(
|
||||
"person_name_taken",
|
||||
"There's already a person with that name",
|
||||
)
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "persons.create", skip_all)]
|
||||
async fn create_person_handler<D: FaceDao>(
|
||||
_: Claims,
|
||||
@@ -2141,7 +2085,7 @@ async fn create_person_handler<D: FaceDao>(
|
||||
face_dao: web::Data<Mutex<D>>,
|
||||
) -> impl Responder {
|
||||
if body.name.trim().is_empty() {
|
||||
return ApiError::bad_request("invalid_person_name", "Enter a name").into_response();
|
||||
return HttpResponse::BadRequest().body("name required");
|
||||
}
|
||||
|
||||
let mut dao = face_dao.lock().expect("face dao lock");
|
||||
@@ -2154,9 +2098,9 @@ async fn create_person_handler<D: FaceDao>(
|
||||
// the outermost context ("insert person ...") which hides the
|
||||
// diesel "UNIQUE constraint failed" we're keying on.
|
||||
if is_unique_violation(&e) {
|
||||
person_name_taken().into_response()
|
||||
HttpResponse::Conflict().body("person name already exists")
|
||||
} else {
|
||||
ApiError::internal(format!("{:#}", e)).into_response()
|
||||
HttpResponse::InternalServerError().body(format!("{:#}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2171,8 +2115,8 @@ async fn get_person_handler<D: FaceDao>(
|
||||
let mut dao = face_dao.lock().expect("face dao lock");
|
||||
match dao.get_person(path.into_inner()) {
|
||||
Ok(Some(p)) => HttpResponse::Ok().json(p),
|
||||
Ok(None) => person_not_found().into_response(),
|
||||
Err(e) => ApiError::internal(e.to_string()).into_response(),
|
||||
Ok(None) => HttpResponse::NotFound().finish(),
|
||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2188,9 +2132,9 @@ async fn update_person_handler<D: FaceDao>(
|
||||
Ok(p) => HttpResponse::Ok().json(p),
|
||||
Err(e) => {
|
||||
if is_unique_violation(&e) {
|
||||
person_name_taken().into_response()
|
||||
HttpResponse::Conflict().body("person name already exists")
|
||||
} else {
|
||||
ApiError::internal(format!("{:#}", e)).into_response()
|
||||
HttpResponse::InternalServerError().body(format!("{:#}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2209,8 +2153,8 @@ async fn delete_person_handler<D: FaceDao>(
|
||||
let mut dao = face_dao.lock().expect("face dao lock");
|
||||
match dao.delete_person(path.into_inner(), cascade) {
|
||||
Ok(true) => HttpResponse::NoContent().finish(),
|
||||
Ok(false) => person_not_found().into_response(),
|
||||
Err(e) => ApiError::internal(e.to_string()).into_response(),
|
||||
Ok(false) => HttpResponse::NotFound().finish(),
|
||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2228,13 +2172,9 @@ async fn merge_persons_handler<D: FaceDao>(
|
||||
Err(e) => {
|
||||
let msg = format!("{:#}", e);
|
||||
if msg.contains("itself") {
|
||||
ApiError::bad_request(
|
||||
"merge_into_self",
|
||||
"A person can't be merged into themselves",
|
||||
)
|
||||
.into_response()
|
||||
HttpResponse::BadRequest().body(msg)
|
||||
} else {
|
||||
ApiError::internal(msg).into_response()
|
||||
HttpResponse::InternalServerError().body(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-52
@@ -1,4 +1,3 @@
|
||||
use crate::error::ApiError;
|
||||
use ::anyhow;
|
||||
use actix::{Handler, Message};
|
||||
use anyhow::Context;
|
||||
@@ -191,11 +190,6 @@ fn in_memory_date_sort(
|
||||
(result, result_libs, total_count)
|
||||
}
|
||||
|
||||
/// The requested folder is outside every library (or traverses out of one).
|
||||
fn folder_not_found() -> ApiError {
|
||||
ApiError::bad_request("folder_not_found", "That folder isn't in the library")
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "list_photos", skip_all)]
|
||||
pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
_: Claims,
|
||||
@@ -294,7 +288,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
Ok(lib) => lib,
|
||||
Err(msg) => {
|
||||
log::warn!("Rejecting /photos request: {}", msg);
|
||||
return crate::libraries::unknown_library(msg).into_response();
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -314,11 +308,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
{
|
||||
warn!("GPS search requires lat, lon, and radius_km to all be specified");
|
||||
span.set_status(Status::error("Invalid GPS parameters"));
|
||||
return ApiError::bad_request(
|
||||
"incomplete_gps_filter",
|
||||
"A location filter needs a latitude, longitude and radius",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().body("GPS search requires lat, lon, and radius_km");
|
||||
}
|
||||
|
||||
// Calculate GPS bounding box if GPS search is requested
|
||||
@@ -574,7 +564,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
span.set_attribute("total_count", total_count.to_string());
|
||||
span.set_status(Status::Ok);
|
||||
|
||||
let (photo_display_paths, photo_version_counts, photo_clip_counts) =
|
||||
let (photo_display_paths, photo_version_counts) =
|
||||
version_arrays(&app_state, &tagged_files, &photo_libraries);
|
||||
HttpResponse::Ok().json(PhotosResponse {
|
||||
photos: tagged_files,
|
||||
@@ -582,14 +572,13 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
photo_libraries,
|
||||
photo_display_paths,
|
||||
photo_version_counts,
|
||||
photo_clip_counts,
|
||||
total_count: pagination_metadata.0,
|
||||
has_more: pagination_metadata.1,
|
||||
next_offset: pagination_metadata.2,
|
||||
})
|
||||
})
|
||||
.into_http_internal_err()
|
||||
.unwrap_or_else(|e| actix_web::ResponseError::error_response(&e));
|
||||
.unwrap_or_else(|e| e.error_response());
|
||||
}
|
||||
|
||||
// In scoped mode `libraries_to_scan` has one entry (the selected library);
|
||||
@@ -733,24 +722,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
if !any_library_resolved {
|
||||
error!("Bad photos request: {}", req.path);
|
||||
span.set_status(Status::error("Invalid path"));
|
||||
return folder_not_found().into_response();
|
||||
}
|
||||
|
||||
// A folder whose indexed contents are all hidden versions (typically
|
||||
// `aiedits/`) would open empty: leave it out unless versions are asked
|
||||
// for. Recursive listings are flat and carry no dirs.
|
||||
if !search_recursively && !include_versions && !dirs_set.is_empty() {
|
||||
let candidates: Vec<String> = dirs_set.iter().cloned().collect();
|
||||
let view: Vec<i32> = library.into_iter().map(|l| l.id).collect();
|
||||
match app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.dirs_holding_only_hidden_versions(&view, &candidates)
|
||||
{
|
||||
Ok(empty) => dirs_set.retain(|d| !empty.contains(d.trim_end_matches('/'))),
|
||||
Err(e) => warn!("dirs_holding_only_hidden_versions failed: {:?}", e),
|
||||
}
|
||||
return HttpResponse::BadRequest().finish();
|
||||
}
|
||||
|
||||
let dirs: Vec<String> = dirs_set.into_iter().collect();
|
||||
@@ -937,7 +909,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
span.set_attribute("total_count", total_count.to_string());
|
||||
span.set_status(Status::Ok);
|
||||
|
||||
let (photo_display_paths, photo_version_counts, photo_clip_counts) =
|
||||
let (photo_display_paths, photo_version_counts) =
|
||||
version_arrays(&app_state, &response_files, &response_libraries);
|
||||
HttpResponse::Ok().json(PhotosResponse {
|
||||
photos: response_files,
|
||||
@@ -945,7 +917,6 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
photo_libraries: response_libraries,
|
||||
photo_display_paths,
|
||||
photo_version_counts,
|
||||
photo_clip_counts,
|
||||
total_count: pagination_metadata.0,
|
||||
has_more: pagination_metadata.1,
|
||||
next_offset: pagination_metadata.2,
|
||||
@@ -972,26 +943,24 @@ fn hidden_version_rows(
|
||||
}
|
||||
|
||||
/// The `/photos` parallel arrays for a page: `photo_display_paths` (the file
|
||||
/// to show when the global choice isn't the original),
|
||||
/// `photo_version_counts` and `photo_clip_counts`. Each is empty, so skipped
|
||||
/// on the wire, when no photo on the page has anything to report.
|
||||
/// 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>, Vec<u32>) {
|
||||
) -> (Vec<Option<String>>, Vec<u32>) {
|
||||
let summaries = crate::versions::load_summaries(&app_state.version_dao);
|
||||
if summaries.is_empty() {
|
||||
return (Vec::new(), Vec::new(), Vec::new());
|
||||
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());
|
||||
let mut clips: 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));
|
||||
clips.push(s.map_or(0, |s| s.clip_count));
|
||||
}
|
||||
if display.iter().all(Option::is_none) {
|
||||
display.clear();
|
||||
@@ -999,10 +968,7 @@ fn version_arrays(
|
||||
if counts.iter().all(|c| *c == 0) {
|
||||
counts.clear();
|
||||
}
|
||||
if clips.iter().all(|c| *c == 0) {
|
||||
clips.clear();
|
||||
}
|
||||
(display, counts, clips)
|
||||
(display, counts)
|
||||
}
|
||||
|
||||
fn sort(
|
||||
@@ -1222,7 +1188,9 @@ pub async fn get_gps_summary(
|
||||
if !validated {
|
||||
warn!("Invalid path for GPS summary: {}", req.path);
|
||||
span.set_status(Status::error("Invalid path"));
|
||||
return Ok(folder_not_found().into_response());
|
||||
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "Invalid path"
|
||||
})));
|
||||
}
|
||||
req.path.clone()
|
||||
};
|
||||
@@ -1296,7 +1264,9 @@ pub async fn get_gps_summary(
|
||||
Err(e) => {
|
||||
error!("Error querying GPS data: {:?}", e);
|
||||
span.set_status(Status::error(format!("Database error: {:?}", e)));
|
||||
Ok(ApiError::internal("Failed to query GPS data").into_response())
|
||||
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to query GPS data"
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1349,7 +1319,7 @@ pub async fn list_exif_summary(
|
||||
Ok(lib) => lib.map(|l| l.id),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
return Ok(crate::libraries::unknown_library(msg).into_response());
|
||||
return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": msg })));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1399,7 +1369,9 @@ pub async fn list_exif_summary(
|
||||
Err(e) => {
|
||||
error!("Error querying EXIF batch: {:?}", e);
|
||||
span.set_status(Status::error(format!("Database error: {:?}", e)));
|
||||
Ok(ApiError::internal("Failed to query EXIF data").into_response())
|
||||
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to query EXIF data"
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1443,9 +1415,9 @@ pub async fn move_file<FS: FileSystemAccess>(
|
||||
);
|
||||
|
||||
if e == ErrorKind::InvalidData {
|
||||
ApiError::bad_request("invalid_move", "Can't move the file there").into_response()
|
||||
HttpResponse::BadRequest().finish()
|
||||
} else {
|
||||
ApiError::internal("internal error").into_response()
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-22
@@ -2,7 +2,6 @@
|
||||
//! and shared across libraries — a favorite created in lib1 is visible
|
||||
//! under lib2 if the same rel_path resolves there too.
|
||||
|
||||
use crate::error::ApiError;
|
||||
use std::sync::Mutex;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
@@ -47,7 +46,6 @@ pub async fn favorites(
|
||||
photo_libraries: Vec::new(),
|
||||
photo_display_paths: Vec::new(),
|
||||
photo_version_counts: Vec::new(),
|
||||
photo_clip_counts: Vec::new(),
|
||||
total_count: None,
|
||||
has_more: None,
|
||||
next_offset: None,
|
||||
@@ -55,9 +53,10 @@ pub async fn favorites(
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
span.set_status(Status::error(format!("Error getting favorites: {:?}", e)));
|
||||
ApiError::internal(format!("Error getting favorites: {:?}", e)).into_response()
|
||||
error!("Error getting favorites: {:?}", e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
Err(e) => ApiError::internal(e).into_response(),
|
||||
Err(_) => HttpResponse::InternalServerError().finish(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,24 +78,24 @@ pub async fn put_add_favorite(
|
||||
{
|
||||
Ok(Err(e)) if e.kind == DbErrorKind::AlreadyExists => {
|
||||
warn!("Favorite: {} exists for user: {}", &body.path, user_id);
|
||||
HttpResponse::Ok().finish()
|
||||
HttpResponse::Ok()
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!("{:?} {}. for user: {}", e, body.path, user_id);
|
||||
HttpResponse::BadRequest()
|
||||
}
|
||||
Ok(Err(e)) => ApiError::internal(format!(
|
||||
"{:?} adding favorite {}. for user: {}",
|
||||
e, body.path, user_id
|
||||
))
|
||||
.into_response(),
|
||||
Ok(Ok(_)) => {
|
||||
info!("Adding favorite \"{}\" for userid: {}", body.path, user_id);
|
||||
HttpResponse::Created().finish()
|
||||
HttpResponse::Created()
|
||||
}
|
||||
Err(e) => {
|
||||
ApiError::internal(format!("Blocking error while inserting favorite: {:?}", e))
|
||||
.into_response()
|
||||
error!("Blocking error while inserting favorite: {:?}", e);
|
||||
HttpResponse::InternalServerError()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bad_user(&claims).into_response()
|
||||
error!("Unable to parse sub as i32: {}", claims.sub);
|
||||
HttpResponse::BadRequest()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,14 +120,9 @@ pub async fn delete_favorite(
|
||||
"Removing favorite \"{}\" for userid: {}",
|
||||
body.path, user_id
|
||||
);
|
||||
HttpResponse::Ok().finish()
|
||||
HttpResponse::Ok()
|
||||
} else {
|
||||
bad_user(&claims).into_response()
|
||||
error!("Unable to parse sub as i32: {}", claims.sub);
|
||||
HttpResponse::BadRequest()
|
||||
}
|
||||
}
|
||||
|
||||
/// A token whose subject isn't a user id (favorites are per user).
|
||||
fn bad_user(claims: &Claims) -> ApiError {
|
||||
error!("Unable to parse sub as i32: {}", claims.sub);
|
||||
ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||
}
|
||||
+29
-91
@@ -2,7 +2,6 @@
|
||||
//! legacy thumbnail lookup), upload, EXIF metadata read + GPS / date
|
||||
//! mutation, and the full exiftool dump used by Apollo's details modal.
|
||||
|
||||
use crate::error::ApiError;
|
||||
use std::error::Error;
|
||||
use std::fs::File;
|
||||
use std::io::ErrorKind;
|
||||
@@ -57,7 +56,7 @@ pub async fn get_image(
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
return libraries::unknown_library(msg).into_response();
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,11 +231,7 @@ pub async fn get_image(
|
||||
return resp;
|
||||
}
|
||||
span.set_status(Status::error("Thumbnail unavailable"));
|
||||
return ApiError::not_found(
|
||||
"thumbnail_unavailable",
|
||||
"No thumbnail for this file yet",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,11 +264,11 @@ pub async fn get_image(
|
||||
}
|
||||
|
||||
span.set_status(Status::error("Not found"));
|
||||
file_not_found().into_response()
|
||||
HttpResponse::NotFound().finish()
|
||||
} else {
|
||||
span.set_status(Status::error("Not found"));
|
||||
error!("Path does not exist in any library: {}", req.path);
|
||||
file_not_found().into_response()
|
||||
HttpResponse::NotFound().finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,7 +557,7 @@ pub async fn get_file_metadata(
|
||||
error!("{}", message);
|
||||
span.set_status(Status::error(message));
|
||||
|
||||
ApiError::internal("internal error").into_response()
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -614,23 +609,19 @@ pub async fn set_image_gps(
|
||||
Some(v) => v,
|
||||
None => {
|
||||
span.set_status(Status::error("file not found"));
|
||||
return file_not_found().into_response();
|
||||
return HttpResponse::NotFound().body("File not found");
|
||||
}
|
||||
};
|
||||
|
||||
if !exif::supports_exif(&full_path) {
|
||||
return ApiError::bad_request(
|
||||
"gps_write_unsupported",
|
||||
"Location can't be saved into this file type",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().body("File format does not support EXIF GPS write");
|
||||
}
|
||||
|
||||
if let Err(e) = exif::write_gps(&full_path, body.latitude, body.longitude) {
|
||||
let msg = format!("exiftool write failed: {}", e);
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
return ApiError::internal(msg).into_response();
|
||||
return HttpResponse::InternalServerError().body(msg);
|
||||
}
|
||||
|
||||
// Re-read EXIF from disk (the write path doesn't tell us the rest of
|
||||
@@ -646,7 +637,7 @@ pub async fn set_image_gps(
|
||||
// next file scan rewrites it.
|
||||
let msg = format!("EXIF re-read failed after write: {}", e);
|
||||
error!("{}", msg);
|
||||
return ApiError::internal(msg).into_response();
|
||||
return HttpResponse::InternalServerError().body(msg);
|
||||
}
|
||||
};
|
||||
let now = Utc::now().timestamp();
|
||||
@@ -751,7 +742,7 @@ pub async fn set_image_gps(
|
||||
let msg = format!("EXIF DB update failed: {:?}", e);
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
ApiError::internal(msg).into_response()
|
||||
HttpResponse::InternalServerError().body(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -799,7 +790,7 @@ pub async fn get_full_exif(
|
||||
Some(v) => v,
|
||||
None => {
|
||||
span.set_status(Status::error("file not found"));
|
||||
return file_not_found().into_response();
|
||||
return HttpResponse::NotFound().body("File not found");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -834,17 +825,13 @@ pub async fn get_full_exif(
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
// 503 — typically "exiftool isn't on PATH" or a transient spawn
|
||||
// failure. Apollo surfaces a hint in the modal.
|
||||
ApiError::unavailable(
|
||||
"exiftool_unavailable",
|
||||
"Couldn't write to the file: exiftool isn't available on the server",
|
||||
)
|
||||
.into_response()
|
||||
HttpResponse::ServiceUnavailable().body(msg)
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("blocking-pool error: {}", e);
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
ApiError::internal(msg).into_response()
|
||||
HttpResponse::InternalServerError().body(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -939,29 +926,6 @@ fn earliest_fs_time(response: &MetadataResponse) -> Option<i64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A manual date on a photo version (#191) is overwritten by the next
|
||||
/// metadata sync (`sync_version_metadata` re-copies the original's date), so
|
||||
/// the edit is redirected to the original, as tag writes are. Returns the
|
||||
/// `(library, path)` the edit applies to: the original's when `path` is a
|
||||
/// linked version with a live original, else the inputs unchanged.
|
||||
fn date_edit_target<'a>(
|
||||
app_state: &'a AppState,
|
||||
library: &'a libraries::Library,
|
||||
path: &str,
|
||||
) -> (&'a libraries::Library, String) {
|
||||
let original = app_state
|
||||
.version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.original_location_of(library.id, path)
|
||||
.ok()
|
||||
.flatten();
|
||||
match original {
|
||||
Some((lib_id, rel)) => (app_state.library_by_id(lib_id).unwrap_or(library), rel),
|
||||
None => (library, path.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/image/exif/date")]
|
||||
#[tracing::instrument(name = "set_image_date", skip_all)]
|
||||
pub async fn set_image_date(
|
||||
@@ -978,14 +942,13 @@ pub async fn set_image_date(
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
return libraries::unknown_library(msg).into_response();
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
|
||||
// Path normalization matches set_image_gps so a Windows-import client
|
||||
// doesn't end up with a backslash variant that misses the row.
|
||||
let normalized_path = body.path.replace('\\', "/");
|
||||
let (library, normalized_path) = date_edit_target(&app_state, library, &normalized_path);
|
||||
|
||||
// The user's display zone — the wall the UI sent is rendered in this
|
||||
// zone (ADR-0001, ticket #115). NULL / unparseable prefs fall back to
|
||||
@@ -1021,8 +984,8 @@ pub async fn set_image_date(
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
match e.kind {
|
||||
DbErrorKind::NotFound => not_indexed().into_response(),
|
||||
_ => ApiError::internal(msg).into_response(),
|
||||
DbErrorKind::NotFound => HttpResponse::NotFound().body(msg),
|
||||
_ => HttpResponse::InternalServerError().body(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1044,12 +1007,11 @@ pub async fn clear_image_date(
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
return libraries::unknown_library(msg).into_response();
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
|
||||
let normalized_path = body.path.replace('\\', "/");
|
||||
let (library, normalized_path) = date_edit_target(&app_state, library, &normalized_path);
|
||||
|
||||
let updated = {
|
||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
@@ -1071,8 +1033,8 @@ pub async fn clear_image_date(
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
match e.kind {
|
||||
DbErrorKind::NotFound => not_indexed().into_response(),
|
||||
_ => ApiError::internal(msg).into_response(),
|
||||
DbErrorKind::NotFound => HttpResponse::NotFound().body(msg),
|
||||
_ => HttpResponse::InternalServerError().body(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1102,7 +1064,7 @@ pub async fn upload_image(
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
return libraries::unknown_library(msg).into_response();
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1139,8 +1101,7 @@ pub async fn upload_image(
|
||||
if !file_content.is_empty() {
|
||||
if file_name.is_none() {
|
||||
span.set_status(Status::error("No filename provided"));
|
||||
return ApiError::bad_request("no_file_name", "The upload has no file name")
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().body("No filename provided");
|
||||
}
|
||||
let full_path = PathBuf::from(&path).join(file_name.unwrap());
|
||||
if let Some(full_path) = is_valid_full_path(
|
||||
@@ -1168,22 +1129,14 @@ pub async fn upload_image(
|
||||
.find(|l| l.id == existing.library_id)
|
||||
.map(|l| l.name);
|
||||
span.set_status(Status::Ok);
|
||||
// The extra fields predate the error format; clients
|
||||
// read them at the top level.
|
||||
return ApiError::conflict(
|
||||
"duplicate_upload",
|
||||
"This photo is already in your library",
|
||||
)
|
||||
.with_legacy_field(
|
||||
"duplicate_of",
|
||||
serde_json::json!({
|
||||
return HttpResponse::Conflict().json(serde_json::json!({
|
||||
"duplicate_of": {
|
||||
"library_id": existing.library_id,
|
||||
"rel_path": existing.file_path,
|
||||
}),
|
||||
)
|
||||
.with_legacy_field("content_hash", &upload_hash)
|
||||
.with_legacy_field("library_name", &library_name)
|
||||
.into_response();
|
||||
},
|
||||
"content_hash": upload_hash,
|
||||
"library_name": library_name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1296,13 +1249,11 @@ pub async fn upload_image(
|
||||
} else {
|
||||
error!("Invalid path for upload: {:?}", full_path);
|
||||
span.set_status(Status::error("Invalid path for upload"));
|
||||
return ApiError::bad_request("invalid_path", "That folder isn't in the library")
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().body("Path was not valid");
|
||||
}
|
||||
} else {
|
||||
span.set_status(Status::error("No file body read"));
|
||||
return ApiError::bad_request("empty_upload", "The upload has no file in it")
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().body("No file body read");
|
||||
}
|
||||
|
||||
app_state.stream_manager.do_send(RefreshThumbnailsMessage);
|
||||
@@ -1342,19 +1293,6 @@ fn sniff_image_mime(head: &[u8]) -> Option<actix_web::mime::Mime> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The path names no file in any library.
|
||||
fn file_not_found() -> ApiError {
|
||||
ApiError::not_found("file_not_found", "That file doesn't exist")
|
||||
}
|
||||
|
||||
/// The file exists but has no indexed row yet.
|
||||
fn not_indexed() -> ApiError {
|
||||
ApiError::not_found(
|
||||
"photo_not_indexed",
|
||||
"This photo is still being indexed. Try again in a minute.",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+30
-51
@@ -1,7 +1,6 @@
|
||||
//! Video-related endpoints: HLS playlist generation, segment streaming,
|
||||
//! and the short-clip preview pipeline.
|
||||
|
||||
use crate::error::ApiError;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
@@ -76,7 +75,7 @@ pub async fn generate_video(
|
||||
let resolved = resolve_request_path(&app_state, &body.path, body.library.as_deref());
|
||||
let Some((resolved_library_id, resolved_root, full_path)) = resolved else {
|
||||
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
||||
return invalid_path().into_response();
|
||||
return HttpResponse::BadRequest().finish();
|
||||
};
|
||||
|
||||
// Build the rel_path used to look up the row. Forward-slash normalized
|
||||
@@ -113,7 +112,7 @@ pub async fn generate_video(
|
||||
e
|
||||
);
|
||||
span.set_status(Status::error(format!("hash compute failed: {}", e)));
|
||||
return ApiError::internal("internal error").into_response();
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -199,13 +198,11 @@ pub async fn stream_hls_file(
|
||||
let (hash, file) = path.into_inner();
|
||||
if !is_valid_hash(&hash) {
|
||||
span.set_status(Status::error("invalid hash"));
|
||||
return ApiError::bad_request("invalid_path", "Invalid video stream address")
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().body("invalid hash");
|
||||
}
|
||||
if !is_allowed_hls_filename(&file) {
|
||||
span.set_status(Status::error("invalid file"));
|
||||
return ApiError::bad_request("invalid_path", "Invalid video stream address")
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().body("invalid file");
|
||||
}
|
||||
|
||||
let shard = &hash[..2];
|
||||
@@ -223,7 +220,7 @@ pub async fn stream_hls_file(
|
||||
Err(e) => {
|
||||
error!("Failed to canonicalize VIDEO_PATH: {:?}", e);
|
||||
span.set_status(Status::error("VIDEO_PATH not canonicalisable"));
|
||||
return ApiError::internal("internal error").into_response();
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
let canonical_file = match std::fs::canonicalize(&file_path) {
|
||||
@@ -231,7 +228,7 @@ pub async fn stream_hls_file(
|
||||
Err(_) => {
|
||||
debug!("HLS file not found: {}", file_path.display());
|
||||
span.set_status(Status::error("not found"));
|
||||
return stream_not_found().into_response();
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
};
|
||||
if !canonical_file.starts_with(&canonical_base) {
|
||||
@@ -240,7 +237,7 @@ pub async fn stream_hls_file(
|
||||
file_path.display()
|
||||
);
|
||||
span.set_status(Status::error("traversal"));
|
||||
return ApiError::forbidden("invalid_path", "Invalid video stream address").into_response();
|
||||
return HttpResponse::Forbidden().finish();
|
||||
}
|
||||
|
||||
match NamedFile::open(&canonical_file) {
|
||||
@@ -250,7 +247,7 @@ pub async fn stream_hls_file(
|
||||
}
|
||||
Err(_) => {
|
||||
span.set_status(Status::error("not found"));
|
||||
stream_not_found().into_response()
|
||||
HttpResponse::NotFound().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,8 +263,8 @@ fn is_valid_hash(s: &str) -> bool {
|
||||
///
|
||||
/// Normalizing to forward slashes is essential on Windows: `file_scan`
|
||||
/// stores rel_paths forward-slash regardless of OS, but a raw strip of a
|
||||
/// backslash Windows path (`Z:\...\pic\album\clip.mp4`) yields
|
||||
/// `album\clip.mp4`. `get_exif_batch` does an exact match with no
|
||||
/// backslash Windows path (`Z:\...\pic\Melissa\clip.mp4`) yields
|
||||
/// `Melissa\clip.mp4`. `get_exif_batch` does an exact match with no
|
||||
/// normalization, so the backslash form misses and the handler falls back
|
||||
/// to re-hashing the entire file on every request.
|
||||
/// Response body for `POST /video/original`.
|
||||
@@ -297,11 +294,11 @@ pub async fn original_video_url(
|
||||
resolve_request_path(&app_state, &body.path, body.library.as_deref())
|
||||
else {
|
||||
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
||||
return invalid_path().into_response();
|
||||
return HttpResponse::BadRequest().finish();
|
||||
};
|
||||
if !file_types::is_video_file(&full_path) {
|
||||
span.set_status(Status::error("not a video"));
|
||||
return ApiError::bad_request("not_a_video", "That file isn't a video").into_response();
|
||||
return HttpResponse::BadRequest().body("not a video");
|
||||
}
|
||||
|
||||
let rel_path = rel_path_for_lookup(&full_path.to_string_lossy(), &root);
|
||||
@@ -375,11 +372,7 @@ pub async fn stream_original_video(
|
||||
.and_then(|t| original_url::verify(&t.0));
|
||||
let Some(claims) = claims else {
|
||||
span.set_status(Status::error("bad token"));
|
||||
return ApiError::unauthorized(
|
||||
"stream_link_expired",
|
||||
"This video link has expired. Open the video again.",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::Unauthorized().finish();
|
||||
};
|
||||
let Some(path) = app_state
|
||||
.libraries
|
||||
@@ -389,7 +382,7 @@ pub async fn stream_original_video(
|
||||
.filter(|p| p.is_file())
|
||||
else {
|
||||
span.set_status(Status::error("not found"));
|
||||
return file_not_found().into_response();
|
||||
return HttpResponse::NotFound().finish();
|
||||
};
|
||||
|
||||
match NamedFile::open(&path) {
|
||||
@@ -401,7 +394,7 @@ pub async fn stream_original_video(
|
||||
}
|
||||
Err(_) => {
|
||||
span.set_status(Status::error("not found"));
|
||||
stream_not_found().into_response()
|
||||
HttpResponse::NotFound().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,7 +472,7 @@ pub async fn get_video_preview(
|
||||
Some(path) => path,
|
||||
None => {
|
||||
span.set_status(Status::error("Invalid path"));
|
||||
return invalid_path().into_response();
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Invalid path"}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -539,7 +532,9 @@ pub async fn get_video_preview(
|
||||
.error_message
|
||||
.unwrap_or_else(|| "Unknown error".to_string());
|
||||
span.set_status(Status::error(format!("Generation failed: {}", error_msg)));
|
||||
ApiError::internal(format!("Generation failed: {}", error_msg)).into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Generation failed: {}", error_msg)
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
// pending or unknown status - trigger generation
|
||||
@@ -574,7 +569,7 @@ pub async fn get_video_preview(
|
||||
}
|
||||
Err(_) => {
|
||||
span.set_status(Status::error("Database error"));
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -592,9 +587,8 @@ pub async fn get_preview_status(
|
||||
// Limit to 200 paths per request
|
||||
if body.paths.len() > 200 {
|
||||
span.set_status(Status::error("Too many paths"));
|
||||
return ApiError::bad_request("too_many_files", "At most 200 videos per request")
|
||||
.with_details(serde_json::json!({ "max": 200 }))
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "Maximum 200 paths per request"}));
|
||||
}
|
||||
|
||||
let previews = {
|
||||
@@ -671,26 +665,11 @@ pub async fn get_preview_status(
|
||||
}
|
||||
Err(_) => {
|
||||
span.set_status(Status::error("Database error"));
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The path names no file in the library.
|
||||
fn file_not_found() -> ApiError {
|
||||
ApiError::not_found("file_not_found", "That file doesn't exist")
|
||||
}
|
||||
|
||||
/// The path isn't a file in the library (400, as it has always been here).
|
||||
fn invalid_path() -> ApiError {
|
||||
ApiError::bad_request("invalid_path", "That file isn't in the library")
|
||||
}
|
||||
|
||||
/// An HLS playlist/segment that isn't (or is no longer) generated.
|
||||
fn stream_not_found() -> ApiError {
|
||||
ApiError::not_found("stream_not_found", "This video isn't ready to stream yet")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -737,19 +716,19 @@ mod tests {
|
||||
#[test]
|
||||
fn rel_path_for_lookup_normalizes_windows_separators() {
|
||||
// Windows: backslash root + backslash full path. The stored row is
|
||||
// forward-slash (`album/clip.mp4`), so without normalization the
|
||||
// forward-slash (`Melissa/clip.mp4`), so without normalization the
|
||||
// lookup misses and the handler re-hashes the whole file.
|
||||
assert_eq!(
|
||||
rel_path_for_lookup(r"Z:\Media\pic\album\clip.mp4", r"Z:\Media\pic"),
|
||||
"album/clip.mp4"
|
||||
rel_path_for_lookup(r"Z:\Media\pic\Melissa\clip.mp4", r"Z:\Media\pic"),
|
||||
"Melissa/clip.mp4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rel_path_for_lookup_handles_unix_separators() {
|
||||
assert_eq!(
|
||||
rel_path_for_lookup("/media/pic/album/clip.mp4", "/media/pic"),
|
||||
"album/clip.mp4"
|
||||
rel_path_for_lookup("/media/pic/Melissa/clip.mp4", "/media/pic"),
|
||||
"Melissa/clip.mp4"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -786,8 +765,8 @@ mod tests {
|
||||
// If the root doesn't prefix the path (e.g. a stale mount), we keep
|
||||
// the whole path but still normalize separators rather than panic.
|
||||
assert_eq!(
|
||||
rel_path_for_lookup(r"D:\other\album\clip.mp4", r"Z:\Media\pic"),
|
||||
"D:/other/album/clip.mp4"
|
||||
rel_path_for_lookup(r"D:\other\Melissa\clip.mp4", r"Z:\Media\pic"),
|
||||
"D:/other/Melissa/clip.mp4"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+68
-79
@@ -1,4 +1,3 @@
|
||||
use crate::error::ApiError;
|
||||
use actix_web::dev::{ServiceFactory, ServiceRequest};
|
||||
use actix_web::{App, HttpRequest, HttpResponse, Responder, web};
|
||||
use chrono::Utc;
|
||||
@@ -520,7 +519,7 @@ async fn list_entities<D: KnowledgeDao + 'static>(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("list_entities error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,12 +538,12 @@ async fn get_entity<D: KnowledgeDao + 'static>(
|
||||
let entity = match dao.get_entity_by_id(entity_id) {
|
||||
Ok(Some(e)) => e,
|
||||
Ok(None) => {
|
||||
return ApiError::not_found("entity_not_found", "That entity no longer exists")
|
||||
.into_response();
|
||||
return HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("get_entity error: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -553,7 +552,8 @@ async fn get_entity<D: KnowledgeDao + 'static>(
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::error!("get_facts_for_entity error: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -644,7 +644,8 @@ async fn get_entity<D: KnowledgeDao + 'static>(
|
||||
Ok(links) => links.into_iter().map(PhotoLinkDetail::from).collect(),
|
||||
Err(e) => {
|
||||
log::error!("get_links_for_entity error: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -692,12 +693,10 @@ async fn patch_entity<D: KnowledgeDao + 'static>(
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
match dao.update_entity(entity_id, patch) {
|
||||
Ok(Some(entity)) => HttpResponse::Ok().json(EntitySummary::from(entity)),
|
||||
Ok(None) => {
|
||||
ApiError::not_found("entity_not_found", "That entity no longer exists").into_response()
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"})),
|
||||
Err(e) => {
|
||||
log::error!("patch_entity error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -713,12 +712,12 @@ async fn delete_entity<D: KnowledgeDao + 'static>(
|
||||
// Verify entity exists before deleting
|
||||
match dao.get_entity_by_id(entity_id) {
|
||||
Ok(None) => {
|
||||
return ApiError::not_found("entity_not_found", "That entity no longer exists")
|
||||
.into_response();
|
||||
return HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("delete_entity lookup error: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
}
|
||||
@@ -727,7 +726,7 @@ async fn delete_entity<D: KnowledgeDao + 'static>(
|
||||
Ok(()) => HttpResponse::NoContent().finish(),
|
||||
Err(e) => {
|
||||
log::error!("delete_entity error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -738,8 +737,8 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
|
||||
dao: web::Data<Mutex<D>>,
|
||||
) -> impl Responder {
|
||||
if body.source_id == body.target_id {
|
||||
return ApiError::bad_request("merge_into_self", "An entity can't be merged into itself")
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "source_id and target_id must be different"}));
|
||||
}
|
||||
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
@@ -748,15 +747,13 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
|
||||
for id in [body.source_id, body.target_id] {
|
||||
match dao.get_entity_by_id(id) {
|
||||
Ok(None) => {
|
||||
return ApiError::bad_request(
|
||||
"entity_not_found",
|
||||
format!("Entity {} no longer exists", id),
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": format!("Entity {} not found", id)}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("merge_entities lookup error: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
}
|
||||
@@ -771,7 +768,7 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
|
||||
}),
|
||||
Err(e) => {
|
||||
log::error!("merge_entities error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -793,8 +790,8 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
|
||||
app_state: web::Data<AppState>,
|
||||
) -> impl Responder {
|
||||
if body.source_id == body.target_id {
|
||||
return ApiError::bad_request("merge_into_self", "An entity can't be merged into itself")
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "source_id and target_id must differ"}));
|
||||
}
|
||||
|
||||
let (source, target) = {
|
||||
@@ -803,29 +800,25 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
|
||||
let source = match dao.get_entity_by_id(body.source_id) {
|
||||
Ok(Some(e)) => e,
|
||||
Ok(None) => {
|
||||
return ApiError::bad_request(
|
||||
"entity_not_found",
|
||||
"The entity to merge no longer exists",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "source entity not found"}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("synthesize_merge source lookup: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
};
|
||||
let target = match dao.get_entity_by_id(body.target_id) {
|
||||
Ok(Some(e)) => e,
|
||||
Ok(None) => {
|
||||
return ApiError::bad_request(
|
||||
"entity_not_found",
|
||||
"The entity to merge into no longer exists",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "target entity not found"}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("synthesize_merge target lookup: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
};
|
||||
(source, target)
|
||||
@@ -897,11 +890,9 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("synthesize_merge generate failed: {:?}", e);
|
||||
return ApiError::unavailable(
|
||||
"llm_unavailable",
|
||||
"The language model is unavailable. Merge without a summary, or try again later.",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::ServiceUnavailable().json(serde_json::json!({
|
||||
"error": "LLM unavailable; the merge picker should fall back to skip-synthesis."
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -976,7 +967,7 @@ async fn list_facts<D: KnowledgeDao + 'static>(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("list_facts error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -989,11 +980,13 @@ async fn create_fact<D: KnowledgeDao + 'static>(
|
||||
persona_dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
if body.object_entity_id.is_none() && body.object_value.is_none() {
|
||||
return ApiError::bad_request("invalid_fact", "A fact needs an object entity or a value")
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "object_entity_id or object_value is required"
|
||||
}));
|
||||
}
|
||||
if body.predicate.trim().is_empty() {
|
||||
return ApiError::bad_request("invalid_fact", "A fact needs a predicate").into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "predicate must not be empty"}));
|
||||
}
|
||||
|
||||
// Persona scoping: facts are written under the active single persona.
|
||||
@@ -1013,15 +1006,14 @@ async fn create_fact<D: KnowledgeDao + 'static>(
|
||||
// Verify subject entity exists.
|
||||
match dao.get_entity_by_id(body.subject_entity_id) {
|
||||
Ok(None) => {
|
||||
return ApiError::bad_request(
|
||||
"entity_not_found",
|
||||
"The fact's subject no longer exists",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("Subject entity {} not found", body.subject_entity_id)
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("create_fact subject lookup error: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
}
|
||||
@@ -1030,15 +1022,14 @@ async fn create_fact<D: KnowledgeDao + 'static>(
|
||||
if let Some(oid) = body.object_entity_id {
|
||||
match dao.get_entity_by_id(oid) {
|
||||
Ok(None) => {
|
||||
return ApiError::bad_request(
|
||||
"entity_not_found",
|
||||
"The fact's object no longer exists",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("Object entity {} not found", oid)
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("create_fact object lookup error: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
}
|
||||
@@ -1083,7 +1074,7 @@ async fn create_fact<D: KnowledgeDao + 'static>(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("create_fact upsert error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1110,12 +1101,10 @@ async fn patch_fact<D: KnowledgeDao + 'static>(
|
||||
// agent corrections.
|
||||
match dao.update_fact(fact_id, patch, Some(("manual", "manual"))) {
|
||||
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
||||
Ok(None) => {
|
||||
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"})),
|
||||
Err(e) => {
|
||||
log::error!("patch_fact error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1131,7 +1120,7 @@ async fn delete_fact<D: KnowledgeDao + 'static>(
|
||||
Ok(()) => HttpResponse::NoContent().finish(),
|
||||
Err(e) => {
|
||||
log::warn!("delete_fact({}) error: {:?}", fact_id, e);
|
||||
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
|
||||
HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1144,8 +1133,8 @@ async fn supersede_fact<D: KnowledgeDao + 'static>(
|
||||
) -> impl Responder {
|
||||
let old_id = id.into_inner();
|
||||
if old_id == body.by_fact_id {
|
||||
return ApiError::bad_request("supersede_self", "A fact can't replace itself")
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "old_id and by_fact_id must differ"}));
|
||||
}
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
// Manual supersede from the curation UI — same stamping rule as
|
||||
@@ -1153,11 +1142,11 @@ async fn supersede_fact<D: KnowledgeDao + 'static>(
|
||||
match dao.supersede_fact(old_id, body.by_fact_id, Some(("manual", "manual"))) {
|
||||
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
||||
Ok(None) => {
|
||||
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
|
||||
HttpResponse::NotFound().json(serde_json::json!({"error": "Old or new fact not found"}))
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("supersede_fact error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1171,12 +1160,10 @@ async fn restore_fact<D: KnowledgeDao + 'static>(
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
match dao.revert_supersession(fact_id, Some(("manual", "manual"))) {
|
||||
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
||||
Ok(None) => {
|
||||
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"})),
|
||||
Err(e) => {
|
||||
log::error!("restore_fact error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1206,7 +1193,7 @@ async fn get_recent<D: KnowledgeDao + 'static>(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("get_recent error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1230,7 +1217,7 @@ async fn get_predicate_stats<D: KnowledgeDao + 'static>(
|
||||
}),
|
||||
Err(e) => {
|
||||
log::error!("get_predicate_stats error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1244,7 +1231,8 @@ async fn bulk_reject_predicate<D: KnowledgeDao + 'static>(
|
||||
) -> impl Responder {
|
||||
let predicate = predicate.into_inner();
|
||||
if predicate.trim().is_empty() {
|
||||
return ApiError::bad_request("invalid_fact", "A fact needs a predicate").into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "predicate must not be empty"}));
|
||||
}
|
||||
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
@@ -1252,7 +1240,7 @@ async fn bulk_reject_predicate<D: KnowledgeDao + 'static>(
|
||||
Ok(rejected) => HttpResponse::Ok().json(BulkRejectResponse { rejected }),
|
||||
Err(e) => {
|
||||
log::error!("bulk_reject_predicate error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1290,7 +1278,7 @@ async fn get_graph<D: KnowledgeDao + 'static>(
|
||||
}),
|
||||
Err(e) => {
|
||||
log::error!("build_entity_graph error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1314,7 +1302,8 @@ async fn get_consolidation_proposals<D: KnowledgeDao + 'static>(
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
log::error!("find_consolidation_proposals: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+8
-36
@@ -1,4 +1,3 @@
|
||||
use crate::error::ApiError;
|
||||
use actix_web::{HttpResponse, Responder, get, patch, web, web::Data};
|
||||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
@@ -320,22 +319,6 @@ pub fn resolve_library_param<'a>(
|
||||
/// Resolve a library request parameter against the AppState's libraries.
|
||||
/// Returns `Ok(None)` when the param is absent, meaning "span all libraries".
|
||||
/// Returns `Err` when a value is provided but does not match any library.
|
||||
/// The response to a `library` param that names no library.
|
||||
pub fn unknown_library(reason: impl std::fmt::Display) -> ApiError {
|
||||
ApiError::bad_request(
|
||||
"unknown_library",
|
||||
"That library doesn't exist on this server",
|
||||
)
|
||||
.with_details(serde_json::json!({ "reason": reason.to_string() }))
|
||||
}
|
||||
|
||||
fn library_not_found() -> ApiError {
|
||||
ApiError::not_found(
|
||||
"library_not_found",
|
||||
"That library doesn't exist on this server",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_library_param_state<'a>(
|
||||
state: &'a AppState,
|
||||
param: Option<&str>,
|
||||
@@ -563,12 +546,7 @@ pub async fn patch_library(
|
||||
let body = body.into_inner();
|
||||
|
||||
if body.enabled.is_none() && body.excluded_dirs.is_none() {
|
||||
return ApiError::new(
|
||||
actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"nothing_to_change",
|
||||
"Nothing to change",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::UnprocessableEntity().body("empty patch body");
|
||||
}
|
||||
|
||||
let mut conn = crate::database::connect();
|
||||
@@ -588,7 +566,7 @@ pub async fn patch_library(
|
||||
"PATCH /libraries/{}: enabled update failed: {:?}",
|
||||
lib_id, e
|
||||
);
|
||||
return ApiError::internal(format!("{}", e)).into_response();
|
||||
return HttpResponse::InternalServerError().body(format!("{}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -601,15 +579,7 @@ pub async fn patch_library(
|
||||
// back as 422 so the operator can fix the typo.
|
||||
let normalised = match normalize_excluded_dirs_input(raw) {
|
||||
Ok(v) => v,
|
||||
// The message names the entry at fault and how to fix it.
|
||||
Err(msg) => {
|
||||
return ApiError::new(
|
||||
actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid_excluded_dirs",
|
||||
msg,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(msg) => return HttpResponse::UnprocessableEntity().body(msg),
|
||||
};
|
||||
let stored: Option<&str> = normalised.as_deref();
|
||||
match diesel::update(libraries::table.filter(libraries::id.eq(lib_id)))
|
||||
@@ -622,13 +592,13 @@ pub async fn patch_library(
|
||||
"PATCH /libraries/{}: excluded_dirs update failed: {:?}",
|
||||
lib_id, e
|
||||
);
|
||||
return ApiError::internal(format!("{}", e)).into_response();
|
||||
return HttpResponse::InternalServerError().body(format!("{}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if affected == 0 {
|
||||
return library_not_found().into_response();
|
||||
return HttpResponse::NotFound().body(format!("library id {} not found", lib_id));
|
||||
}
|
||||
|
||||
// Refresh the live view from the canonical DB state. Reloading the
|
||||
@@ -653,7 +623,9 @@ pub async fn patch_library(
|
||||
);
|
||||
HttpResponse::Ok().json(lib)
|
||||
}
|
||||
None => library_not_found().into_response(),
|
||||
None => {
|
||||
HttpResponse::NotFound().body(format!("library id {} not found after update", lib_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-9
@@ -11,7 +11,8 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use actix_cors::Cors;
|
||||
use actix_governor::{Governor, GovernorConfigBuilder};
|
||||
use actix_web::{App, HttpServer, middleware, web};
|
||||
use actix_multipart as mp;
|
||||
use actix_web::{App, HttpResponse, HttpServer, middleware, web};
|
||||
use diesel::sqlite::Sqlite;
|
||||
use std::error::Error;
|
||||
|
||||
@@ -343,7 +344,6 @@ fn main() -> std::io::Result<()> {
|
||||
.service(enhance::get_templates)
|
||||
.service(enhance::submit)
|
||||
.service(enhance::list_jobs)
|
||||
.service(enhance::recent_prompts)
|
||||
.service(enhance::get_job)
|
||||
.service(enhance::preview)
|
||||
.service(enhance::approve)
|
||||
@@ -442,13 +442,19 @@ fn main() -> std::io::Result<()> {
|
||||
.app_data::<Data<image_api::ai::face_client::FaceClient>>(Data::new(
|
||||
app_data.face_client.clone(),
|
||||
))
|
||||
.app_data(image_api::error::multipart_config(1024 * 1024 * 1024)) // 1GB upload limit
|
||||
// Request-parsing failures and unknown routes answer in the
|
||||
// shared error format (#202), not actix's plain text.
|
||||
.app_data(image_api::error::json_config())
|
||||
.app_data(image_api::error::query_config())
|
||||
.app_data(image_api::error::path_config())
|
||||
.default_service(web::to(image_api::error::unknown_route))
|
||||
.app_data(mp::form::MultipartFormConfig::default().total_limit(1024 * 1024 * 1024)) // 1GB upload limit
|
||||
.app_data(web::JsonConfig::default().error_handler(|err, req| {
|
||||
let detail = err.to_string();
|
||||
log::warn!(
|
||||
"JSON parse error on {} {}: {}",
|
||||
req.method(),
|
||||
req.uri(),
|
||||
detail
|
||||
);
|
||||
let response =
|
||||
HttpResponse::BadRequest().json(serde_json::json!({"error": detail}));
|
||||
actix_web::error::InternalError::from_response(err, response).into()
|
||||
}))
|
||||
.app_data::<Data<InsightGenerator>>(Data::new(app_data.insight_generator.clone()))
|
||||
.wrap(prometheus.clone())
|
||||
})
|
||||
|
||||
+2
-7
@@ -1,4 +1,3 @@
|
||||
use crate::error::ApiError;
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{HttpResponse, Responder, get, web};
|
||||
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
||||
@@ -570,11 +569,7 @@ pub async fn list_memories(
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
warn!("Rejecting /memories request: {}", msg);
|
||||
return ApiError::bad_request(
|
||||
"invalid_date",
|
||||
"That date isn't valid (expected YYYY-MM-DD)",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -589,7 +584,7 @@ pub async fn list_memories(
|
||||
Ok(items) => items,
|
||||
Err(msg) => {
|
||||
warn!("Rejecting /memories request: {}", msg);
|
||||
return crate::libraries::unknown_library(msg).into_response();
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+33
-41
@@ -8,7 +8,6 @@
|
||||
//! migration. Customs are created here and may be migrated up from a
|
||||
//! device's local store via `POST /personas/migrate`.
|
||||
|
||||
use crate::error::ApiError;
|
||||
use actix_web::dev::{ServiceFactory, ServiceRequest};
|
||||
use actix_web::{App, HttpResponse, Responder, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -149,8 +148,7 @@ fn user_id_from_claims(claims: &Claims) -> Option<i32> {
|
||||
|
||||
async fn list_personas(claims: Claims, dao: PersonaDaoData) -> impl Responder {
|
||||
let Some(uid) = user_id_from_claims(&claims) else {
|
||||
return ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||
.into_response();
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
||||
};
|
||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||
match dao.list_personas(uid) {
|
||||
@@ -160,7 +158,7 @@ async fn list_personas(claims: Claims, dao: PersonaDaoData) -> impl Responder {
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("list_personas error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,16 +169,15 @@ async fn create_persona(
|
||||
dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
let Some(uid) = user_id_from_claims(&claims) else {
|
||||
return ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||
.into_response();
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
||||
};
|
||||
|
||||
if body.name.trim().is_empty() {
|
||||
return ApiError::bad_request("invalid_persona", "Give the persona a name").into_response();
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "name is required"}));
|
||||
}
|
||||
if body.system_prompt.trim().is_empty() {
|
||||
return ApiError::bad_request("invalid_persona", "Give the persona a system prompt")
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "systemPrompt is required"}));
|
||||
}
|
||||
|
||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||
@@ -191,26 +188,23 @@ async fn create_persona(
|
||||
};
|
||||
|
||||
if matches!(pid.as_str(), "default" | "journal" | "factual") {
|
||||
return ApiError::conflict(
|
||||
"persona_id_taken",
|
||||
"A built-in persona already uses that id",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::Conflict()
|
||||
.json(serde_json::json!({"error": "persona id collides with a built-in"}));
|
||||
}
|
||||
|
||||
// Pre-check existence so we can return 409 cleanly. The DB UNIQUE
|
||||
// would also catch it, but parsing Diesel's "constraint violation"
|
||||
// out of a generic DbError is uglier than a quick lookup.
|
||||
if let Ok(Some(_)) = dao.get_persona(uid, &pid) {
|
||||
return ApiError::conflict("persona_id_taken", "That persona already exists")
|
||||
.into_response();
|
||||
return HttpResponse::Conflict()
|
||||
.json(serde_json::json!({"error": "persona already exists"}));
|
||||
}
|
||||
|
||||
match dao.create_persona(uid, &pid, &body.name, &body.system_prompt, false, false) {
|
||||
Ok(p) => HttpResponse::Created().json(PersonaView::from(p)),
|
||||
Err(e) => {
|
||||
log::error!("create_persona error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,8 +216,7 @@ async fn update_persona(
|
||||
dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
let Some(uid) = user_id_from_claims(&claims) else {
|
||||
return ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||
.into_response();
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
||||
};
|
||||
let pid = path.into_inner();
|
||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||
@@ -239,20 +232,19 @@ async fn update_persona(
|
||||
Ok(Some(p)) if p.is_built_in => {
|
||||
let editing_identity = body.name.is_some() || body.system_prompt.is_some();
|
||||
if editing_identity {
|
||||
return ApiError::conflict(
|
||||
"builtin_persona",
|
||||
"A built-in persona's name and prompt can't be changed",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::Conflict().json(serde_json::json!({
|
||||
"error": "Cannot edit name or systemPrompt of a built-in persona"
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return ApiError::not_found("persona_not_found", "That persona no longer exists")
|
||||
.into_response();
|
||||
return HttpResponse::NotFound()
|
||||
.json(serde_json::json!({"error": "Persona not found"}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("update_persona lookup error: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
}
|
||||
@@ -266,11 +258,12 @@ async fn update_persona(
|
||||
};
|
||||
match dao.update_persona(uid, &pid, patch) {
|
||||
Ok(Some(p)) => HttpResponse::Ok().json(PersonaView::from(p)),
|
||||
Ok(None) => ApiError::not_found("persona_not_found", "That persona no longer exists")
|
||||
.into_response(),
|
||||
Ok(None) => {
|
||||
HttpResponse::NotFound().json(serde_json::json!({"error": "Persona not found"}))
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("update_persona error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,24 +274,24 @@ async fn delete_persona(
|
||||
dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
let Some(uid) = user_id_from_claims(&claims) else {
|
||||
return ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||
.into_response();
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
||||
};
|
||||
let pid = path.into_inner();
|
||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||
|
||||
match dao.get_persona(uid, &pid) {
|
||||
Ok(Some(p)) if p.is_built_in => {
|
||||
return ApiError::conflict("builtin_persona", "Built-in personas can't be deleted")
|
||||
.into_response();
|
||||
return HttpResponse::Conflict()
|
||||
.json(serde_json::json!({"error": "Cannot delete built-in persona"}));
|
||||
}
|
||||
Ok(None) => {
|
||||
return ApiError::not_found("persona_not_found", "That persona no longer exists")
|
||||
.into_response();
|
||||
return HttpResponse::NotFound()
|
||||
.json(serde_json::json!({"error": "Persona not found"}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("delete_persona lookup error: {:?}", e);
|
||||
return ApiError::internal("Database error").into_response();
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
}
|
||||
@@ -307,7 +300,7 @@ async fn delete_persona(
|
||||
Ok(_) => HttpResponse::NoContent().finish(),
|
||||
Err(e) => {
|
||||
log::error!("delete_persona error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,8 +311,7 @@ async fn migrate_personas(
|
||||
dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
let Some(uid) = user_id_from_claims(&claims) else {
|
||||
return ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||
.into_response();
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
||||
};
|
||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||
|
||||
@@ -344,7 +336,7 @@ async fn migrate_personas(
|
||||
Ok(inserted) => HttpResponse::Ok().json(MigrateResponse { inserted }),
|
||||
Err(e) => {
|
||||
log::error!("migrate_personas error: {:?}", e);
|
||||
ApiError::internal("Database error").into_response()
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-18
@@ -18,11 +18,11 @@
|
||||
//! single row, so they're idempotent. The legacy `tz_offset_minutes`
|
||||
//! column is preserved verbatim on every write.
|
||||
|
||||
use crate::error::ApiError;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use chrono_tz::Tz;
|
||||
use serde::de::IntoDeserializer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -264,12 +264,16 @@ pub async fn get_prefs(_claims: Claims, app_state: web::Data<AppState>) -> HttpR
|
||||
let mut dao = match app_state.user_ai_prefs_dao.lock() {
|
||||
Ok(dao) => dao,
|
||||
Err(_) => {
|
||||
return ApiError::internal("prefs store unavailable").into_response();
|
||||
return HttpResponse::InternalServerError().json(json!({
|
||||
"error": "prefs store unavailable"
|
||||
}));
|
||||
}
|
||||
};
|
||||
match dao.get_prefs() {
|
||||
Ok(row) => HttpResponse::Ok().json(row_to_response(row.as_ref())),
|
||||
Err(e) => ApiError::internal(format!("failed to read prefs: {e}")).into_response(),
|
||||
Err(e) => HttpResponse::InternalServerError().json(json!({
|
||||
"error": format!("failed to read prefs: {e}")
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,38 +289,39 @@ pub async fn put_prefs(
|
||||
if let TriState::Val(name) = &patch.display_tz
|
||||
&& !is_valid_display_tz(name)
|
||||
{
|
||||
return ApiError::bad_request("invalid_time_zone", format!("\"{name}\" isn't a time zone"))
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().json(json!({
|
||||
"error": format!("invalid display_tz: {name}")
|
||||
}));
|
||||
}
|
||||
if let TriState::Val(name) = &patch.default_backend
|
||||
&& !is_valid_backend(name)
|
||||
{
|
||||
return ApiError::bad_request(
|
||||
"invalid_backend",
|
||||
format!("\"{name}\" isn't a backend (expected local or hybrid)"),
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().json(json!({
|
||||
"error": format!("invalid default_backend: {name} (expected local|hybrid)")
|
||||
}));
|
||||
}
|
||||
if let TriState::Val(name) = &patch.reel_span
|
||||
&& MemoriesSpan::from_str(name).is_err()
|
||||
{
|
||||
return ApiError::bad_request(
|
||||
"invalid_span",
|
||||
format!("\"{name}\" isn't a reel span (expected day, week or month)"),
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::BadRequest().json(json!({
|
||||
"error": format!("invalid reel_span: {name} (expected day|week|month)")
|
||||
}));
|
||||
}
|
||||
|
||||
let mut dao = match app_state.user_ai_prefs_dao.lock() {
|
||||
Ok(dao) => dao,
|
||||
Err(_) => {
|
||||
return ApiError::internal("prefs store unavailable").into_response();
|
||||
return HttpResponse::InternalServerError().json(json!({
|
||||
"error": "prefs store unavailable"
|
||||
}));
|
||||
}
|
||||
};
|
||||
let existing = match dao.get_prefs() {
|
||||
Ok(row) => row,
|
||||
Err(e) => {
|
||||
return ApiError::internal(format!("failed to read prefs: {e}")).into_response();
|
||||
return HttpResponse::InternalServerError().json(json!({
|
||||
"error": format!("failed to read prefs: {e}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -358,7 +363,9 @@ pub async fn put_prefs(
|
||||
};
|
||||
match dao.upsert_prefs(&upsert) {
|
||||
Ok(()) => HttpResponse::Ok().json(resp),
|
||||
Err(e) => ApiError::internal(format!("failed to write prefs: {e}")).into_response(),
|
||||
Err(e) => HttpResponse::InternalServerError().json(json!({
|
||||
"error": format!("failed to write prefs: {e}")
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-36
@@ -16,7 +16,6 @@ pub mod render;
|
||||
pub mod script;
|
||||
pub mod selector;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
@@ -450,11 +449,9 @@ pub async fn create_reel_handler(
|
||||
insight_dao: web::Data<Mutex<Box<dyn InsightDao>>>,
|
||||
) -> impl Responder {
|
||||
if app_state.llamacpp.is_none() {
|
||||
return ApiError::unavailable(
|
||||
"reels_unavailable",
|
||||
"Reels need the language model and voice backend, which isn't set up on this server",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::ServiceUnavailable().json(json!({
|
||||
"error": "Reel narration needs the LLM/TTS backend (set llamacpp.url in llm.toml)"
|
||||
}));
|
||||
}
|
||||
|
||||
// Owner defaults: explicit request param → stored pref → hard default.
|
||||
@@ -485,15 +482,12 @@ pub async fn create_reel_handler(
|
||||
// check. Insight enrichment + scripting happen in the background job.
|
||||
let (planned, meta) = match selector::resolve(&app_state, &exif_dao, &selector) {
|
||||
Ok(r) => r,
|
||||
Err(msg) => return ApiError::bad_request("invalid_reel_request", msg).into_response(),
|
||||
Err(msg) => return HttpResponse::BadRequest().body(msg),
|
||||
};
|
||||
if planned.is_empty() {
|
||||
return ApiError::new(
|
||||
actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"no_memories",
|
||||
"No photos found for this span",
|
||||
)
|
||||
.into_response();
|
||||
return HttpResponse::UnprocessableEntity().json(json!({
|
||||
"error": "No photo memories found for this span"
|
||||
}));
|
||||
}
|
||||
|
||||
// Flatten every media item across beats (in order) into the cache key — the
|
||||
@@ -615,7 +609,7 @@ pub async fn create_reel_handler(
|
||||
pub async fn reel_status_handler(_claims: Claims, path: web::Path<String>) -> impl Responder {
|
||||
let id_str = path.into_inner();
|
||||
let Ok(id) = Uuid::parse_str(&id_str) else {
|
||||
return ApiError::bad_request("invalid_job_id", "Invalid reel job").into_response();
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
|
||||
};
|
||||
let resp = with_job(id, |job| ReelStatusResponse {
|
||||
job_id: id_str.clone(),
|
||||
@@ -628,7 +622,7 @@ pub async fn reel_status_handler(_claims: Claims, path: web::Path<String>) -> im
|
||||
});
|
||||
match resp {
|
||||
Some(r) => HttpResponse::Ok().json(r),
|
||||
None => ApiError::not_found("job_not_found", "That reel job has expired").into_response(),
|
||||
None => HttpResponse::NotFound().json(json!({ "error": "job not found or expired" })),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,17 +636,17 @@ pub async fn reel_video_handler(
|
||||
) -> impl Responder {
|
||||
let id_str = path.into_inner();
|
||||
let Ok(id) = Uuid::parse_str(&id_str) else {
|
||||
return ApiError::bad_request("invalid_job_id", "Invalid reel job").into_response();
|
||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
|
||||
};
|
||||
let output = with_job(id, |job| job.output_path.clone()).flatten();
|
||||
let Some(path) = output else {
|
||||
return ApiError::not_found("reel_not_ready", "The reel isn't ready yet").into_response();
|
||||
return HttpResponse::NotFound().json(json!({ "error": "reel not ready" }));
|
||||
};
|
||||
match NamedFile::open(&path) {
|
||||
Ok(file) => file.into_response(&request),
|
||||
Err(e) => {
|
||||
log::error!("opening reel mp4 {path:?} failed: {e:?}");
|
||||
reel_missing().into_response()
|
||||
HttpResponse::NotFound().json(json!({ "error": "reel file missing" }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -700,31 +694,32 @@ pub async fn precomputed_reel_handler(
|
||||
.exists_fresh(span, &library_key, RENDER_VERSION as i32, min_generated_at)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return no_precomputed_reel("none").into_response();
|
||||
return HttpResponse::NotFound().json(json!({ "error": "no precomputed reel found" }));
|
||||
}
|
||||
|
||||
// Fetch the latest row for full validity checks.
|
||||
let reel = match dao.latest_for(span, &library_key) {
|
||||
Ok(Some(r)) => r,
|
||||
_ => {
|
||||
return no_precomputed_reel("none").into_response();
|
||||
return HttpResponse::NotFound().json(json!({ "error": "no precomputed reel found" }));
|
||||
}
|
||||
};
|
||||
|
||||
// Validity gate 1: render version must match.
|
||||
if reel.render_version != RENDER_VERSION as i32 {
|
||||
return no_precomputed_reel("stale").into_response();
|
||||
return HttpResponse::NotFound()
|
||||
.json(json!({ "error": "precomputed reel is stale (render version mismatch)" }));
|
||||
}
|
||||
|
||||
// Validity gate 2: output_path must exist.
|
||||
let output = std::path::Path::new(&reel.output_path);
|
||||
if !output.exists() {
|
||||
return no_precomputed_reel("file_missing").into_response();
|
||||
return HttpResponse::NotFound().json(json!({ "error": "precomputed reel file missing" }));
|
||||
}
|
||||
|
||||
// Validity gate 3: age <= max_age (re-checked via min_generated_at).
|
||||
if reel.generated_at < min_generated_at {
|
||||
return no_precomputed_reel("expired").into_response();
|
||||
return HttpResponse::NotFound().json(json!({ "error": "precomputed reel has expired" }));
|
||||
}
|
||||
|
||||
HttpResponse::Ok().json(PrecomputedReelResponse {
|
||||
@@ -747,7 +742,7 @@ pub async fn precomputed_video_handler(
|
||||
Ok(file) => file.into_response(&request),
|
||||
Err(e) => {
|
||||
log::error!("opening precomputed reel {key} failed: {e:?}");
|
||||
reel_missing().into_response()
|
||||
HttpResponse::NotFound().json(json!({ "error": "precomputed reel file missing" }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1373,18 +1368,6 @@ fn sweep_reel_cache(app_state: &AppState) -> usize {
|
||||
removed
|
||||
}
|
||||
|
||||
/// No usable precomputed reel; `reason` (none / stale / file_missing /
|
||||
/// expired) is for debugging, the client just generates one.
|
||||
fn no_precomputed_reel(reason: &str) -> ApiError {
|
||||
ApiError::not_found("no_precomputed_reel", "No ready-made reel for this span")
|
||||
.with_details(json!({ "reason": reason }))
|
||||
}
|
||||
|
||||
/// The job finished but its video file is gone (cleaned up or moved).
|
||||
fn reel_missing() -> ApiError {
|
||||
ApiError::not_found("reel_missing", "The reel's video is gone. Make it again.")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+195
-735
File diff suppressed because it is too large.
Load diff
+18
-20
@@ -27,7 +27,6 @@ use crate::clip_search::{
|
||||
};
|
||||
use crate::data::{Claims, FilterMode, MediaType};
|
||||
use crate::database::ExifDao;
|
||||
use crate::error::ApiError;
|
||||
use crate::file_types::{is_image_file, is_video_file};
|
||||
use crate::geo::{forward_geocode, gps_bounding_box, haversine_distance};
|
||||
use crate::tags::TagDao;
|
||||
@@ -265,7 +264,14 @@ struct UnifiedResponse {
|
||||
results: Vec<SearchHit>,
|
||||
}
|
||||
|
||||
// Errors use the shared format (crate::error::ApiError, #202).
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
}
|
||||
|
||||
fn bad_request(msg: impl Into<String>) -> HttpResponse {
|
||||
HttpResponse::BadRequest().json(ErrorBody { error: msg.into() })
|
||||
}
|
||||
|
||||
/// Combine the model's semantic term with any tag words that didn't match the
|
||||
/// vocab, so a hallucinated/non-vocab tag becomes a soft semantic signal
|
||||
@@ -300,7 +306,7 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
);
|
||||
let nl = query.q.trim().to_string();
|
||||
if nl.is_empty() {
|
||||
return crate::clip_search::empty_query().into_response();
|
||||
return bad_request("query parameter `q` is required");
|
||||
}
|
||||
|
||||
let limit = query.limit.clamp(1, 200);
|
||||
@@ -309,7 +315,7 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
|
||||
let library_ids = match parse_library_scope(query.library_ids.as_deref(), query.library) {
|
||||
Ok(ids) => ids,
|
||||
Err(msg) => return crate::libraries::unknown_library(msg).into_response(),
|
||||
Err(msg) => return bad_request(msg),
|
||||
};
|
||||
|
||||
// ── 1. Translate the NL query, grounded on the real tag vocabulary ──
|
||||
@@ -327,13 +333,7 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
};
|
||||
let (sq, echo, explicit_tags, explicit_gps) = match merge_explicit(sq, &query) {
|
||||
Ok(merged) => merged,
|
||||
Err(_) => {
|
||||
return ApiError::bad_request(
|
||||
"incomplete_gps_filter",
|
||||
"A location filter needs a latitude, longitude and radius",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(msg) => return bad_request(msg),
|
||||
};
|
||||
let folder = folder_prefix(query.path.as_deref());
|
||||
|
||||
@@ -597,8 +597,7 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
// Filters-only: no semantic term. Require at least one filter,
|
||||
// then return the candidate set newest-first.
|
||||
if !has_struct {
|
||||
return ApiError::bad_request("nothing_to_search", "Add search words or a filter")
|
||||
.into_response();
|
||||
return bad_request("query had no searchable terms");
|
||||
}
|
||||
candidate.sort_by(|a, b| b.date_taken.cmp(&a.date_taken));
|
||||
let total_matching = candidate.len();
|
||||
@@ -685,11 +684,9 @@ async fn translate<TagD: TagDao>(
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("unified_search: resolve_backend failed: {e:?}");
|
||||
return Err(ApiError::unavailable(
|
||||
"llm_unavailable",
|
||||
"The language model is unavailable. Try again shortly.",
|
||||
)
|
||||
.into_response());
|
||||
return Err(HttpResponse::ServiceUnavailable().json(ErrorBody {
|
||||
error: "LLM backend unavailable".into(),
|
||||
}));
|
||||
}
|
||||
};
|
||||
log::info!("unified_search: translating with model={}", backend.model());
|
||||
@@ -703,8 +700,9 @@ async fn translate<TagD: TagDao>(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::warn!("unified_search: translate_nl_query failed: {e:?}");
|
||||
ApiError::bad_gateway("query_not_understood", "Couldn't understand that search")
|
||||
.into_response()
|
||||
HttpResponse::BadGateway().json(ErrorBody {
|
||||
error: "could not interpret the query".into(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+20
-115
@@ -110,8 +110,6 @@ pub struct ReviewedLink {
|
||||
pub method: String,
|
||||
pub library_id: i32,
|
||||
pub version_path: String,
|
||||
/// The original's path as the dry run proposed it (may be empty).
|
||||
pub original_path: String,
|
||||
pub kind: String,
|
||||
pub version_hash: String,
|
||||
pub original_hash: String,
|
||||
@@ -142,7 +140,6 @@ pub fn parse_reviewed(tsv: &str) -> (Vec<ReviewedLink>, usize) {
|
||||
method: f[1].to_string(),
|
||||
library_id: f[2].parse().unwrap(),
|
||||
version_path: f[3].to_string(),
|
||||
original_path: f[4].to_string(),
|
||||
kind: f[6].to_string(),
|
||||
version_hash: f[7].to_string(),
|
||||
original_hash: f[8].to_string(),
|
||||
@@ -359,10 +356,7 @@ pub fn plan(libs: &[Library], rows: &[FileRow], linked: &HashSet<String>) -> Vec
|
||||
continue;
|
||||
}
|
||||
let loaders = prompt.as_deref().map(loader_names).unwrap_or_default();
|
||||
// A ComfyUI output names its inputs wherever it was saved (early test
|
||||
// runs went to folders like `edited/` or sat beside the original), so
|
||||
// the loader match isn't limited to aiedits/. It's review-only anyway.
|
||||
if !loaders.is_empty() {
|
||||
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));
|
||||
@@ -412,16 +406,9 @@ fn nearest<'a>(row: &FileRow, candidates: &[&'a FileRow]) -> Option<(&'a FileRow
|
||||
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:
|
||||
// for an output in aiedits/ its parent then the same folder; for one
|
||||
// elsewhere the same folder (saved beside the original) then the parent
|
||||
// (e.g. `edited/`); then anywhere in the library. Version-looking files
|
||||
// never count as originals.
|
||||
let tiers = if is_aiedits_dir(dir) {
|
||||
[parent, dir]
|
||||
} else {
|
||||
[dir, parent]
|
||||
};
|
||||
// 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
|
||||
@@ -435,7 +422,7 @@ fn plan_comfy_loader(row: &FileRow, loaders: &[String], idx: &Index) -> Proposal
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for tier in tiers {
|
||||
for tier in [parent, dir] {
|
||||
let hit: Vec<&FileRow> = all
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -637,8 +624,6 @@ pub struct ApplyReport {
|
||||
pub refused: usize,
|
||||
/// Reviewed rows whose version row no longer matches the file in the TSV.
|
||||
pub stale: usize,
|
||||
/// One line per stale reviewed row, saying what no longer matches.
|
||||
pub stale_details: Vec<String>,
|
||||
}
|
||||
|
||||
/// Write the links. `exact` proposals apply as they are; `reviewed` come
|
||||
@@ -656,8 +641,6 @@ pub fn apply(
|
||||
.map(|r| ((r.library_id, r.rel_path.as_str()), r.content_hash.as_str()))
|
||||
.collect();
|
||||
|
||||
let live_hashes: HashSet<&str> = rows.iter().map(|r| 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 {
|
||||
@@ -676,32 +659,8 @@ pub fn apply(
|
||||
});
|
||||
}
|
||||
for r in reviewed {
|
||||
let stale = if current.get(&(r.library_id, r.version_path.as_str()))
|
||||
!= Some(&r.version_hash.as_str())
|
||||
{
|
||||
Some("version file now holds different bytes (or is gone)")
|
||||
} else if !live_hashes.contains(r.original_hash.as_str()) {
|
||||
Some("original_hash is not in the index any more")
|
||||
} else if !r.original_path.is_empty()
|
||||
&& current
|
||||
.get(&(r.library_id, r.original_path.as_str()))
|
||||
.is_some_and(|h| *h != r.original_hash.as_str())
|
||||
{
|
||||
Some("original path now holds different bytes than original_hash")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(why) = stale {
|
||||
if current.get(&(r.library_id, r.version_path.as_str())) != Some(&r.version_hash.as_str()) {
|
||||
report.stale += 1;
|
||||
report.stale_details.push(format!(
|
||||
"{} -> {}: {why}",
|
||||
r.version_path,
|
||||
if r.original_path.is_empty() {
|
||||
&r.original_hash
|
||||
} else {
|
||||
&r.original_path
|
||||
}
|
||||
));
|
||||
continue;
|
||||
}
|
||||
links.push(NewVersionLink {
|
||||
@@ -824,88 +783,48 @@ mod tests {
|
||||
#[test]
|
||||
fn comfy_outputs_are_resolved_by_loader_and_flagged_when_two_inputs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_comfy_png(tmp.path(), "album_a/aiedits/out_00001_.png", &["a.jpg"], 1);
|
||||
write_comfy_png(tmp.path(), "Amanda/aiedits/out_00001_.png", &["a.jpg"], 1);
|
||||
write_comfy_png(
|
||||
tmp.path(),
|
||||
"album_a/aiedits/out_00002_.png",
|
||||
"Amanda/aiedits/out_00002_.png",
|
||||
&["a.jpg", "b.jpg"],
|
||||
2,
|
||||
);
|
||||
write_comfy_png(
|
||||
tmp.path(),
|
||||
"album_a/aiedits/out_00003_.png",
|
||||
"Amanda/aiedits/out_00003_.png",
|
||||
&["typo.jpg"],
|
||||
3,
|
||||
);
|
||||
let rows = vec![
|
||||
row("album_a/a.jpg", "ha", Some(0b1111)),
|
||||
row("album_a/b.jpg", "hb", Some(-1)),
|
||||
row("album_a/aiedits/out_00001_.png", "v1", Some(0b1110)),
|
||||
row("album_a/aiedits/out_00002_.png", "v2", Some(0b1011)),
|
||||
row("album_a/aiedits/out_00003_.png", "v3", Some(0)),
|
||||
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("album_a/aiedits/out_00001_.png");
|
||||
let one = get("Amanda/aiedits/out_00001_.png");
|
||||
assert_eq!((one.status, one.method), (Status::Review, "comfy-loader"));
|
||||
assert_eq!(one.original_path, "album_a/a.jpg");
|
||||
assert_eq!(one.original_path, "Amanda/a.jpg");
|
||||
assert_eq!(one.distance, Some(1));
|
||||
assert!(one.references.is_empty());
|
||||
|
||||
let two = get("album_a/aiedits/out_00002_.png");
|
||||
let two = get("Amanda/aiedits/out_00002_.png");
|
||||
assert_eq!(
|
||||
two.original_path, "album_a/a.jpg",
|
||||
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("album_a/aiedits/out_00003_.png");
|
||||
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 comfy_outputs_outside_aiedits_resolve_from_their_own_folder_then_the_parent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Saved into a sibling folder: the original is one level up.
|
||||
write_comfy_png(tmp.path(), "album_b/edited/out_00016_.png", &["a.jpg"], 1);
|
||||
// Saved beside the original; a same-named file at the root must lose.
|
||||
write_comfy_png(tmp.path(), "album_c/x-aiedit_00001_.png", &["x.jpg"], 2);
|
||||
// A ComfyUI output with no loader (txt2img) is left alone.
|
||||
write_comfy_png(tmp.path(), "album_c/generated_00001_.png", &[], 3);
|
||||
let rows = vec![
|
||||
row("album_b/a.jpg", "ha", Some(0)),
|
||||
row("album_b/edited/out_00016_.png", "v1", Some(1)),
|
||||
row("x.jpg", "hroot", Some(0)),
|
||||
row("album_c/x.jpg", "hx", Some(0)),
|
||||
row("album_c/x-aiedit_00001_.png", "v2", Some(1)),
|
||||
row("album_c/generated_00001_.png", "g", Some(0)),
|
||||
];
|
||||
let plan = plan(&[lib(tmp.path())], &rows, &HashSet::new());
|
||||
let get = |p: &str| plan.iter().find(|x| x.version_path == p);
|
||||
|
||||
let edited = get("album_b/edited/out_00016_.png").unwrap();
|
||||
assert_eq!(
|
||||
(edited.status, edited.method),
|
||||
(Status::Review, "comfy-loader")
|
||||
);
|
||||
assert_eq!(edited.original_path, "album_b/a.jpg");
|
||||
|
||||
let beside = get("album_c/x-aiedit_00001_.png").unwrap();
|
||||
assert_eq!(beside.status, Status::Review);
|
||||
assert_eq!(
|
||||
beside.original_path, "album_c/x.jpg",
|
||||
"same folder beats the parent"
|
||||
);
|
||||
|
||||
assert!(
|
||||
get("album_c/generated_00001_.png").is_none(),
|
||||
"no loader, not a version"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscayl_names_and_phash_fallback() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1008,19 +927,6 @@ mod tests {
|
||||
stale_rows[2].content_hash = "changed".into();
|
||||
let report = apply(&mut dao, &[], &reviewed, &stale_rows);
|
||||
assert_eq!(report.stale, 1);
|
||||
|
||||
// So is one whose original changed since the dry run: its hash is
|
||||
// gone from the index, or its path holds other bytes.
|
||||
let mut gone = rows.clone();
|
||||
gone[0].content_hash = "ha-edited".into();
|
||||
let report = apply(&mut dao, &[], &reviewed, &gone);
|
||||
assert_eq!(report.stale, 1);
|
||||
assert_eq!(report.linked + report.already_linked, 0);
|
||||
assert!(report.stale_details[0].contains("d/aiedits/o_00001_.png"));
|
||||
let mut swapped = rows.clone();
|
||||
swapped[0].content_hash = "hb".into(); // d/a.jpg now holds hb's bytes
|
||||
let report = apply(&mut dao, &[], &reviewed, &swapped);
|
||||
assert_eq!(report.stale, 1);
|
||||
}
|
||||
#[test]
|
||||
fn apply_drops_derived_data_of_an_already_ingested_version() {
|
||||
@@ -1039,17 +945,16 @@ mod tests {
|
||||
"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(),
|
||||
original_path: "o.jpg".into(),
|
||||
kind: "edit".into(),
|
||||
version_hash: "v".into(),
|
||||
original_hash: "o".into(),
|
||||
references: vec![],
|
||||
}];
|
||||
let rows = vec![row("v.png", "v", None), row("o.jpg", "o", None)];
|
||||
assert_eq!(apply(&mut dao, &[], &reviewed, &rows).linked, 1);
|
||||
|
||||
#[derive(diesel::QueryableByName)]
|
||||
|
||||
+13
-42
@@ -3,7 +3,6 @@
|
||||
//! 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 crate::error::ApiError;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use actix_web::{HttpResponse, Responder, get, put, web};
|
||||
@@ -12,7 +11,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::data::Claims;
|
||||
use crate::database::ExifDao;
|
||||
use crate::database::version_dao::{VersionDao, is_clip_kind};
|
||||
use crate::database::version_dao::VersionDao;
|
||||
use crate::libraries;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::normalize_path;
|
||||
@@ -66,9 +65,6 @@ pub struct VersionsResponse {
|
||||
/// standalone photos).
|
||||
pub original: Option<FileRef>,
|
||||
pub versions: Vec<VersionEntry>,
|
||||
/// Derived clips: videos made from the photo (kind `animated`). Same
|
||||
/// shape as `versions`, but never a display choice.
|
||||
pub clips: Vec<VersionEntry>,
|
||||
/// The globally displayed version; `null` = the original.
|
||||
pub display_hash: Option<String>,
|
||||
}
|
||||
@@ -88,14 +84,14 @@ fn resolve(
|
||||
) -> 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(libraries::unknown_library(msg).into_response()),
|
||||
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(|_| ApiError::internal("internal error").into_response())?;
|
||||
.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.
|
||||
@@ -104,18 +100,14 @@ fn resolve(
|
||||
.iter()
|
||||
.find_map(|r| r.content_hash.clone().map(|h| (r, h)))
|
||||
else {
|
||||
return Err(ApiError::not_found(
|
||||
"photo_not_indexed",
|
||||
"This photo isn't indexed yet. Try again in a minute.",
|
||||
)
|
||||
.into_response());
|
||||
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(|_| ApiError::internal("internal error").into_response())?;
|
||||
.map_err(|_| HttpResponse::InternalServerError().finish())?;
|
||||
Ok(Resolved {
|
||||
original_hash: link.map(|l| l.original_hash).unwrap_or(hash),
|
||||
library_id: row.library_id,
|
||||
@@ -140,7 +132,6 @@ pub fn build_response(
|
||||
|
||||
let files = dao.list_versions(original_hash)?;
|
||||
let mut versions = Vec::with_capacity(files.len());
|
||||
let mut clips = Vec::new();
|
||||
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))?;
|
||||
@@ -155,12 +146,7 @@ pub fn build_response(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let list = if is_clip_kind(&f.kind) {
|
||||
&mut clips
|
||||
} else {
|
||||
&mut versions
|
||||
};
|
||||
list.push(VersionEntry {
|
||||
versions.push(VersionEntry {
|
||||
path: f.rel_path,
|
||||
library_id: f.library_id,
|
||||
hash: f.version_hash,
|
||||
@@ -186,7 +172,6 @@ pub fn build_response(
|
||||
Ok(VersionsResponse {
|
||||
original,
|
||||
versions,
|
||||
clips,
|
||||
display_hash,
|
||||
})
|
||||
}
|
||||
@@ -211,7 +196,7 @@ pub async fn get_versions(
|
||||
Ok(body) => HttpResponse::Ok().json(body),
|
||||
Err(e) => {
|
||||
warn!("get_versions failed: {:?}", e);
|
||||
ApiError::internal("internal error").into_response()
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,22 +233,11 @@ pub async fn put_display(
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("put_display lookup failed: {:?}", e);
|
||||
return ApiError::internal("internal error").into_response();
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
if current.clips.iter().any(|c| c.hash == hash) {
|
||||
return ApiError::bad_request(
|
||||
"clip_not_displayable",
|
||||
"A clip made from this photo can't be shown in its place",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if !current.versions.iter().any(|v| v.hash == hash) {
|
||||
return ApiError::bad_request(
|
||||
"not_a_version",
|
||||
"That file isn't a version of this photo",
|
||||
)
|
||||
.into_response();
|
||||
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);
|
||||
@@ -272,22 +246,19 @@ pub async fn put_display(
|
||||
.iter()
|
||||
.any(|v| v.hash == hash && original_lib.is_none_or(|l| l == v.library_id))
|
||||
{
|
||||
return ApiError::bad_request(
|
||||
"version_in_other_library",
|
||||
"That version isn't in the same library as the original",
|
||||
)
|
||||
.into_response();
|
||||
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 ApiError::internal("internal error").into_response();
|
||||
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);
|
||||
ApiError::internal("internal error").into_response()
|
||||
HttpResponse::InternalServerError().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use diesel::sql_query;
|
||||
use diesel::sql_types::{BigInt, Text};
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
|
||||
use super::xmp::test_png::{mp4_bytes, producer_xmp, write_png};
|
||||
use super::xmp::test_png::{producer_xmp, write_png};
|
||||
use super::*;
|
||||
use crate::data::Claims;
|
||||
use crate::database::test::in_memory_db_connection;
|
||||
@@ -253,46 +253,6 @@ async fn name_link_pairs_raw_and_jpeg_with_the_right_original() {
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn unresolvable_name_candidates_do_not_starve_later_ones() {
|
||||
let fx = fx();
|
||||
// Many matching rows with no original anywhere, then one linkable pair.
|
||||
for i in 0..120 {
|
||||
fx.put(
|
||||
&format!("album_a/aiedits/orphan{i}.jpg.edit.png"),
|
||||
format!("orphan {i}").as_bytes(),
|
||||
);
|
||||
}
|
||||
let original = fx.put("album_b/real.jpg", b"real original");
|
||||
let version = fx.put("album_b/aiedits/real.jpg.restored.png", b"real version");
|
||||
|
||||
let exif = fx.exif_arc();
|
||||
let mut cursor = 0;
|
||||
let mut total = 0;
|
||||
// One 50-row page per call: the old "ORDER BY id LIMIT n" would return
|
||||
// the same 50 orphans forever.
|
||||
for _ in 0..5 {
|
||||
total += sweep_name_links_from(
|
||||
&fx.library,
|
||||
&exif,
|
||||
&fx.state.version_dao,
|
||||
&mut cursor,
|
||||
50,
|
||||
1,
|
||||
);
|
||||
}
|
||||
assert_eq!(total, 1);
|
||||
let link = fx
|
||||
.state
|
||||
.version_dao
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_link(&version)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(link.original_hash, original);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn name_link_waits_for_the_original_to_be_hashed() {
|
||||
let fx = fx();
|
||||
@@ -433,103 +393,6 @@ async fn photos_hides_versions_and_adds_parallel_arrays() {
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn folders_holding_only_hidden_versions_are_left_out_of_dirs() {
|
||||
let (fx, _o, _v1, _v2) = seeded();
|
||||
// A second folder whose aiedits holds a version that is NOT hidden
|
||||
// (its original isn't indexed), and a folder nothing is indexed under.
|
||||
fx.put_png("2004/album_a/aiedits/lost.jpg.edit.png", None, 20);
|
||||
std::fs::create_dir_all(fx.root.join("2004/not_indexed")).unwrap();
|
||||
let app = photos_app!(fx);
|
||||
|
||||
let dirs = |body: &serde_json::Value| -> Vec<String> {
|
||||
let mut d: Vec<String> = body["dirs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
d.sort();
|
||||
d
|
||||
};
|
||||
let (_, body) = get_json!(app, "/photos?path=2004");
|
||||
assert_eq!(dirs(&body), vec!["2004/album_a", "2004/not_indexed"]);
|
||||
|
||||
let (_, body) = get_json!(app, "/photos?path=2004&include_versions=true");
|
||||
assert_eq!(
|
||||
dirs(&body),
|
||||
vec!["2004/aiedits", "2004/album_a", "2004/not_indexed"]
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn a_manual_date_on_a_version_path_lands_on_the_original() {
|
||||
let (fx, original, v1, _v2) = seeded();
|
||||
fx.exec(&format!(
|
||||
"UPDATE image_exif SET date_taken = 1500000000, date_utc = 1500000000, \
|
||||
date_taken_source = 'exif' WHERE content_hash = '{original}'"
|
||||
));
|
||||
let app = awtest::init_service(
|
||||
App::new()
|
||||
.service(crate::handlers::image::set_image_date)
|
||||
.service(crate::handlers::image::clear_image_date)
|
||||
.app_data(fx.state.clone())
|
||||
.app_data(fx.exif.clone()),
|
||||
)
|
||||
.await;
|
||||
let post = |uri: &str, body: serde_json::Value| {
|
||||
awtest::TestRequest::post()
|
||||
.uri(uri)
|
||||
.insert_header(("Authorization", format!("Bearer {}", Fx::token())))
|
||||
.set_json(body)
|
||||
.to_request()
|
||||
};
|
||||
let resp = awtest::call_service(
|
||||
&app,
|
||||
post(
|
||||
"/image/exif/date",
|
||||
serde_json::json!({"path": "2004/aiedits/scan.jpg.restored.png", "date_taken": 1_400_000_000}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
|
||||
assert_eq!(
|
||||
fx.count(&format!(
|
||||
"SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{original}' \
|
||||
AND date_taken_source = 'manual'"
|
||||
)),
|
||||
1,
|
||||
"the original carries the manual date"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.count(&format!(
|
||||
"SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{v1}' \
|
||||
AND date_taken_source = 'manual'"
|
||||
)),
|
||||
0,
|
||||
"the version row is left to the metadata sync"
|
||||
);
|
||||
|
||||
// The same redirect applies to clearing it.
|
||||
let resp = awtest::call_service(
|
||||
&app,
|
||||
post(
|
||||
"/image/exif/date/clear",
|
||||
serde_json::json!({"path": "2004/aiedits/scan.jpg.restored.png"}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(resp.status().is_success());
|
||||
assert_eq!(
|
||||
fx.count(&format!(
|
||||
"SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{original}' \
|
||||
AND date_taken_source = 'manual'"
|
||||
)),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn photos_without_versions_is_unchanged() {
|
||||
let fx = fx();
|
||||
@@ -782,51 +645,6 @@ async fn display_choice_set_from_a_version_path_cleared_and_validated() {
|
||||
assert_eq!(awtest::call_service(&app, req).await.status(), 200);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn an_animated_clip_links_hides_and_is_listed_apart_from_versions() {
|
||||
let fx = fx();
|
||||
let original = fx.put("2014/IMG_1.JPG", b"photo bytes");
|
||||
let rel = "2014/aiedits/IMG_1.JPG.animated.mp4";
|
||||
let clip = fx.put(
|
||||
rel,
|
||||
&mp4_bytes(Some(&producer_xmp(&original, "animated", &[])), 3),
|
||||
);
|
||||
let files = vec![(fx.root.join(rel), rel.to_string())];
|
||||
assert_eq!(
|
||||
link_new_files(&fx.library, &files, &fx.exif_arc(), &fx.state.version_dao),
|
||||
1
|
||||
);
|
||||
let app = photos_app!(fx);
|
||||
|
||||
// Hidden behind the photo, like a version, and counted as a clip, not a
|
||||
// version (so the viewer knows to fetch /image/versions for it).
|
||||
let (_, body) = get_json!(app, "/photos?path=&recursive=true&sort=nameasc");
|
||||
assert_eq!(body["photos"], serde_json::json!(["2014/IMG_1.JPG"]));
|
||||
assert!(body.get("photo_version_counts").is_none(), "{body}");
|
||||
assert_eq!(body["photo_clip_counts"], serde_json::json!([1]));
|
||||
|
||||
// Listed under `clips`, from the photo's path or the clip's own.
|
||||
for path in ["2014/IMG_1.JPG", rel] {
|
||||
let (status, body) = get_json!(app, &format!("/image/versions?path={path}"));
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(body["versions"], serde_json::json!([]));
|
||||
assert_eq!(body["clips"][0]["hash"], clip);
|
||||
assert_eq!(body["clips"][0]["kind"], "animated");
|
||||
assert_eq!(body["clips"][0]["path"], rel);
|
||||
}
|
||||
|
||||
// Never the photo's display version.
|
||||
let req = awtest::TestRequest::put()
|
||||
.uri("/image/versions/display")
|
||||
.insert_header(("Authorization", format!("Bearer {}", Fx::token())))
|
||||
.set_json(serde_json::json!({"path": "2014/IMG_1.JPG", "display_hash": clip}))
|
||||
.to_request();
|
||||
let resp = awtest::call_service(&app, req).await;
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = awtest::read_body_json(resp).await;
|
||||
assert_eq!(body["code"], "clip_not_displayable");
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn deleting_the_displayed_version_falls_back_to_the_original() {
|
||||
let (fx, _o, v1, _v2) = seeded();
|
||||
|
||||
+38
-114
@@ -6,7 +6,7 @@
|
||||
//! read-only rule for library roots holds. This module owns
|
||||
//!
|
||||
//! - the link rules at ingest ([`link_new_files`], [`sweep_name_links`]):
|
||||
//! the XMP `DerivedFrom` hash first (PNG, or MP4 / MOV for a clip), then the
|
||||
//! 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`).
|
||||
//!
|
||||
@@ -132,16 +132,18 @@ pub fn name_link(
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the XMP link of a version file on disk (PNG, or MP4 / MOV for a
|
||||
/// derived clip). `None` when the file isn't one of those, can't be read, or
|
||||
/// carries no `DerivedFrom` blake3 id.
|
||||
/// 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> {
|
||||
if !xmp::has_readable_xmp(abs_path) {
|
||||
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) {
|
||||
@@ -211,14 +213,14 @@ pub fn link_new_files(
|
||||
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
version_dao: &Arc<Mutex<Box<dyn VersionDao>>>,
|
||||
) -> usize {
|
||||
let candidates: Vec<&(PathBuf, String)> = files
|
||||
let pngs: Vec<&(PathBuf, String)> = files
|
||||
.iter()
|
||||
.filter(|(p, _)| xmp::has_readable_xmp(p))
|
||||
.filter(|(p, _)| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("png")))
|
||||
.collect();
|
||||
if candidates.is_empty() {
|
||||
if pngs.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let rels: Vec<String> = candidates.iter().map(|(_, r)| r.clone()).collect();
|
||||
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());
|
||||
@@ -243,7 +245,7 @@ pub fn link_new_files(
|
||||
.collect();
|
||||
|
||||
let mut linked = 0;
|
||||
for (abs, rel) in candidates {
|
||||
for (abs, rel) in pngs {
|
||||
let Some(hash) = hashes.get(rel.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
@@ -264,83 +266,26 @@ pub fn sweep_name_links(
|
||||
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
version_dao: &Arc<Mutex<Box<dyn VersionDao>>>,
|
||||
) -> usize {
|
||||
let mut cursor = name_sweep_cursors()
|
||||
let candidates = match version_dao
|
||||
.lock()
|
||||
.expect("name sweep cursors")
|
||||
.get(&library.id)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let linked = sweep_name_links_from(
|
||||
library,
|
||||
exif_dao,
|
||||
version_dao,
|
||||
&mut cursor,
|
||||
NAME_SWEEP_LIMIT,
|
||||
NAME_SWEEP_PAGES,
|
||||
);
|
||||
name_sweep_cursors()
|
||||
.lock()
|
||||
.expect("name sweep cursors")
|
||||
.insert(library.id, cursor);
|
||||
linked
|
||||
}
|
||||
|
||||
/// Pages examined per sweep call.
|
||||
const NAME_SWEEP_PAGES: usize = 4;
|
||||
|
||||
/// Per-library resume point (last candidate row id visited) for the name
|
||||
/// sweep. Candidates that never resolve (no original in the library) stay
|
||||
/// matching forever; paging past them keeps them from starving the rows
|
||||
/// behind them. In memory only: a restart just rescans from the start.
|
||||
fn name_sweep_cursors() -> &'static Mutex<std::collections::HashMap<i32, i32>> {
|
||||
static CURSORS: std::sync::OnceLock<Mutex<std::collections::HashMap<i32, i32>>> =
|
||||
std::sync::OnceLock::new();
|
||||
CURSORS.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// One sweep: up to `max_pages` pages of `page` candidates from `cursor`,
|
||||
/// advancing it and wrapping to 0 past the last candidate (then continuing
|
||||
/// from the start, so a call never ends having examined nothing).
|
||||
fn sweep_name_links_from(
|
||||
library: &Library,
|
||||
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
version_dao: &Arc<Mutex<Box<dyn VersionDao>>>,
|
||||
cursor: &mut i32,
|
||||
page: i64,
|
||||
max_pages: usize,
|
||||
) -> usize {
|
||||
let mut linked = 0;
|
||||
let mut wrapped = *cursor == 0;
|
||||
for _ in 0..max_pages {
|
||||
let candidates = match version_dao
|
||||
.lock()
|
||||
.expect("Unable to lock VersionDao")
|
||||
.unlinked_name_candidates(library.id, *cursor, page)
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"versions: name sweep failed for '{}': {:?}",
|
||||
library.name, e
|
||||
);
|
||||
return linked;
|
||||
}
|
||||
};
|
||||
let short = (candidates.len() as i64) < page;
|
||||
for (id, rel, hash) in candidates {
|
||||
*cursor = id;
|
||||
if let Some(link) = name_link(library, &rel, &hash, exif_dao)
|
||||
&& record_link(version_dao, &link)
|
||||
{
|
||||
linked += 1;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
if short {
|
||||
*cursor = 0;
|
||||
if wrapped {
|
||||
break;
|
||||
}
|
||||
wrapped = true;
|
||||
};
|
||||
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
|
||||
@@ -363,34 +308,25 @@ pub fn link_pending(
|
||||
/// 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. `clip_count` counts derived clips (videos made from the
|
||||
/// photo), which are not versions. `/photos` carries the same facts as the
|
||||
/// parallel arrays `photo_display_paths` / `photo_version_counts` /
|
||||
/// `photo_clip_counts` instead.
|
||||
/// 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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub clip_count: Option<u32>,
|
||||
}
|
||||
|
||||
impl VersionFields {
|
||||
pub fn from_summary(summary: Option<&version_dao::PhotoVersionSummary>) -> Self {
|
||||
let Some(s) = summary else {
|
||||
return VersionFields::default();
|
||||
};
|
||||
let mut fields = VersionFields::default();
|
||||
if s.version_count > 0 {
|
||||
fields.display_path = Some(s.display.as_ref().map(|(_, p)| p.clone()));
|
||||
fields.version_count = Some(s.version_count);
|
||||
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(),
|
||||
}
|
||||
if s.clip_count > 0 {
|
||||
fields.clip_count = Some(s.clip_count);
|
||||
}
|
||||
fields
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,7 +396,6 @@ mod tests {
|
||||
assert_eq!(json(None), serde_json::json!({"path": "a.jpg"}));
|
||||
let plain = version_dao::PhotoVersionSummary {
|
||||
version_count: 2,
|
||||
clip_count: 0,
|
||||
display: None,
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -469,7 +404,6 @@ mod tests {
|
||||
);
|
||||
let shown = version_dao::PhotoVersionSummary {
|
||||
version_count: 1,
|
||||
clip_count: 0,
|
||||
display: Some((1, "aiedits/a.jpg.restored.png".into())),
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -480,16 +414,6 @@ mod tests {
|
||||
"version_count": 1
|
||||
})
|
||||
);
|
||||
// Clips only: no display/version fields, just the clip count.
|
||||
let clips_only = version_dao::PhotoVersionSummary {
|
||||
version_count: 0,
|
||||
clip_count: 2,
|
||||
display: None,
|
||||
};
|
||||
assert_eq!(
|
||||
json(Some(&clips_only)),
|
||||
serde_json::json!({"path": "a.jpg", "clip_count": 2})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+2
-144
@@ -1,8 +1,6 @@
|
||||
//! 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.
|
||||
//! Video versions (MP4 / MOV, e.g. a clip animated from a photo) carry the
|
||||
//! same packet in a top-level `uuid` box with the XMP UUID.
|
||||
//!
|
||||
//! 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.
|
||||
@@ -163,77 +161,9 @@ pub fn parse_version_xmp(xmp: &str) -> Option<VersionXmp> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The XMP UUID (Adobe XMP spec part 3): a top-level `uuid` box with this
|
||||
/// id holds the packet in MP4 / MOV files.
|
||||
const XMP_UUID: [u8; 16] = [
|
||||
0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac,
|
||||
];
|
||||
|
||||
/// The XMP packet of an MP4 / MOV file, if it has one. Walks the top-level
|
||||
/// box headers only, seeking over `mdat`, so a large clip costs a few small
|
||||
/// reads. `Ok(None)` for a file that isn't box-structured.
|
||||
pub fn read_mp4_xmp(path: &Path) -> io::Result<Option<String>> {
|
||||
let mut reader = BufReader::new(File::open(path)?);
|
||||
let file_len = reader.get_ref().metadata()?.len();
|
||||
let mut pos: u64 = 0;
|
||||
while pos + 8 <= file_len {
|
||||
let mut head = [0u8; 8];
|
||||
reader.read_exact(&mut head)?;
|
||||
let mut size = u64::from(u32::from_be_bytes([head[0], head[1], head[2], head[3]]));
|
||||
let kind = [head[4], head[5], head[6], head[7]];
|
||||
let mut header_len = 8u64;
|
||||
if size == 1 {
|
||||
let mut big = [0u8; 8];
|
||||
reader.read_exact(&mut big)?;
|
||||
size = u64::from_be_bytes(big);
|
||||
header_len = 16;
|
||||
} else if size == 0 {
|
||||
size = file_len - pos; // runs to the end of the file
|
||||
}
|
||||
if size < header_len || pos + size > file_len {
|
||||
return Ok(None); // not an MP4, or truncated: no link
|
||||
}
|
||||
let body = size - header_len;
|
||||
if &kind == b"uuid" && body >= 16 && body - 16 <= u64::from(MAX_TEXT_CHUNK) {
|
||||
let mut id = [0u8; 16];
|
||||
reader.read_exact(&mut id)?;
|
||||
if id == XMP_UUID {
|
||||
let mut data = vec![0u8; (body - 16) as usize];
|
||||
reader.read_exact(&mut data)?;
|
||||
return Ok(Some(String::from_utf8_lossy(&data).into_owned()));
|
||||
}
|
||||
reader.seek_relative((body - 16) as i64)?;
|
||||
} else {
|
||||
reader.seek(SeekFrom::Current(body as i64))?;
|
||||
}
|
||||
pos += size;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// File types a version's XMP link is read from.
|
||||
pub fn has_readable_xmp(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
|
||||
["png", "mp4", "mov"]
|
||||
.iter()
|
||||
.any(|x| e.eq_ignore_ascii_case(x))
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the version link out of a PNG, MP4 or MOV file; `None` if it has
|
||||
/// none (or isn't one of those).
|
||||
/// 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>> {
|
||||
let is_png = path
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("png"));
|
||||
let xmp = if is_png {
|
||||
read_png_xmp(path)?
|
||||
} else if has_readable_xmp(path) {
|
||||
read_mp4_xmp(path)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(xmp.and_then(|x| parse_version_xmp(&x)))
|
||||
Ok(read_png_xmp(path)?.and_then(|x| parse_version_xmp(&x)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -247,28 +177,6 @@ pub(crate) mod test_png {
|
||||
out.extend_from_slice(&[0, 0, 0, 0]); // CRC: the reader never checks it
|
||||
}
|
||||
|
||||
fn mp4_box(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
out.extend_from_slice(&((data.len() + 8) as u32).to_be_bytes());
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
|
||||
/// An MP4-shaped byte stream as the producer writes it: ftyp, moov,
|
||||
/// mdat, then the optional XMP `uuid` box. Not playable; enough for the
|
||||
/// box walker (unique per `salt`).
|
||||
pub fn mp4_bytes(xmp: Option<&str>, salt: u8) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
mp4_box(&mut out, b"ftyp", b"isom\0\0\x02\0");
|
||||
mp4_box(&mut out, b"moov", &[1; 24]);
|
||||
mp4_box(&mut out, b"mdat", &[salt; 64]);
|
||||
if let Some(x) = xmp {
|
||||
let mut d = super::XMP_UUID.to_vec();
|
||||
d.extend_from_slice(x.as_bytes());
|
||||
mp4_box(&mut out, b"uuid", &d);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -339,56 +247,6 @@ mod tests {
|
||||
/// 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 reads_the_link_from_an_mp4_clip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let clip = dir.path().join("IMG_1.JPG.animated.mp4");
|
||||
let xmp = REAL_XMP.replace("restored", "animated");
|
||||
std::fs::write(&clip, mp4_bytes(Some(&xmp), 1)).unwrap();
|
||||
let v = read_version_xmp(&clip).unwrap().unwrap();
|
||||
assert_eq!(v.original_hash, ORIG);
|
||||
assert_eq!(v.kind.as_deref(), Some("animated"));
|
||||
// Same bytes under .mov are read too; other extensions aren't.
|
||||
let mov = dir.path().join("clip.MOV");
|
||||
std::fs::write(&mov, mp4_bytes(Some(&xmp), 1)).unwrap();
|
||||
assert!(read_version_xmp(&mov).unwrap().is_some());
|
||||
let avi = dir.path().join("clip.avi");
|
||||
std::fs::write(&avi, mp4_bytes(Some(&xmp), 1)).unwrap();
|
||||
assert!(read_version_xmp(&avi).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_without_xmp_or_not_an_mp4_has_no_link() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let plain = dir.path().join("plain.mp4");
|
||||
std::fs::write(&plain, mp4_bytes(None, 2)).unwrap();
|
||||
assert_eq!(read_mp4_xmp(&plain).unwrap(), None);
|
||||
let junk = dir.path().join("junk.mp4");
|
||||
std::fs::write(&junk, b"definitely not boxes at all").unwrap();
|
||||
assert_eq!(read_mp4_xmp(&junk).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_64bit_box_sizes_are_walked() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&16u32.to_be_bytes());
|
||||
data.extend_from_slice(b"ftypisom\0\0\x02\0");
|
||||
// mdat with a 64-bit size field
|
||||
data.extend_from_slice(&1u32.to_be_bytes());
|
||||
data.extend_from_slice(b"mdat");
|
||||
data.extend_from_slice(&(16u64 + 8).to_be_bytes());
|
||||
data.extend_from_slice(&[7; 8]);
|
||||
let mut d = XMP_UUID.to_vec();
|
||||
d.extend_from_slice(REAL_XMP.as_bytes());
|
||||
data.extend_from_slice(&((d.len() + 8) as u32).to_be_bytes());
|
||||
data.extend_from_slice(b"uuid");
|
||||
data.extend_from_slice(&d);
|
||||
let f = dir.path().join("big.mp4");
|
||||
std::fs::write(&f, data).unwrap();
|
||||
assert_eq!(read_mp4_xmp(&f).unwrap().as_deref(), Some(REAL_XMP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_real_producer_packet() {
|
||||
let x = parse_version_xmp(REAL_XMP).unwrap();
|
||||
|
||||
Reference in new issue
Block a user