Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbbd4470a5 |
+2
-96
@@ -53,60 +53,11 @@ AGENTIC_CHAT_MAX_ITERATIONS=6
|
||||
# OPENROUTER_HTTP_REFERER=https://your-site.example
|
||||
# OPENROUTER_APP_TITLE=ImageApi
|
||||
|
||||
# ── AI Insights — local backend switch ──────────────────────────────────
|
||||
# Picks which local LLM stack the server uses for chat, vision describe,
|
||||
# and embeddings. `ollama` (default) uses the OLLAMA_* settings above;
|
||||
# `llamacpp` uses the LLAMA_SWAP_* settings below. The switch is global
|
||||
# and applies to both `backend=local` and `backend=hybrid` (hybrid keeps
|
||||
# chat on OpenRouter but still uses this stack for the describe pass).
|
||||
# Don't flip mid-deploy without re-embedding existing index rows —
|
||||
# mixed vector spaces break similarity search.
|
||||
# LLM_BACKEND=ollama
|
||||
|
||||
# ── AI Insights — llama.cpp / llama-swap (optional) ─────────────────────
|
||||
# Set LLAMA_SWAP_URL plus LLM_BACKEND=llamacpp to swap the local stack
|
||||
# off Ollama. Talks OpenAI-compatible /v1 to a llama-swap proxy fronting
|
||||
# per-slot llama-server instances. Chat models receive images directly
|
||||
# via content-parts (vision-capable models assumed); a separate vision
|
||||
# slot is used only by the describe_photo tool and describe-image utility.
|
||||
# LLAMA_SWAP_URL=http://localhost:9292/v1
|
||||
# LLAMA_SWAP_PRIMARY_MODEL=chat
|
||||
# Optional dedicated vision slot for describe_image. Defaults to
|
||||
# PRIMARY_MODEL so describe_photo works without extra config.
|
||||
# LLAMA_SWAP_VISION_MODEL=vision
|
||||
# LLAMA_SWAP_EMBEDDING_MODEL=embed
|
||||
# Comma-separated allowlist surfaced by /insights/models when
|
||||
# LLM_BACKEND=llamacpp. All report has_vision=true.
|
||||
# LLAMA_SWAP_ALLOWED_MODELS=chat,vision,embed
|
||||
# LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS=180
|
||||
|
||||
# ── Unified search translation model (optional) ─────────────────────────
|
||||
# /photos/search/unified runs one small LLM call to translate a natural-
|
||||
# language query into structured filters + a semantic term, then CLIP-ranks.
|
||||
# That step needs an LLM AND CLIP available at once. On a tight VRAM budget a
|
||||
# large chat model can't co-reside with CLIP, so pin a small, fast model here
|
||||
# (it can stay loaded alongside CLIP and the chat model). Precedence:
|
||||
# UNIFIED_SEARCH_MODEL > the client's selected model > the configured default.
|
||||
# Use the configured backend (LLM_BACKEND); local only — no hybrid.
|
||||
# UNIFIED_SEARCH_MODEL=qwen3-0.6b
|
||||
|
||||
# ── Text-to-speech (optional, requires LLAMA_SWAP_URL) ───────────────────
|
||||
# TTS routes through the same llama-swap proxy (a Chatterbox model id), so it
|
||||
# only needs LLAMA_SWAP_URL — it does NOT require LLM_BACKEND=llamacpp.
|
||||
# Powers POST /tts/speech and the /tts/voices* endpoints (read-aloud insights
|
||||
# + voice cloning in the mobile app).
|
||||
# LLAMA_SWAP_TTS_MODEL=chatterbox # TTS model id in config.yaml
|
||||
# LLAMA_SWAP_TTS_VOICE=m # default voice when a request omits one
|
||||
# LLAMA_SWAP_TTS_REF_SECONDS=30 # max voice-clone reference clip length (s)
|
||||
# LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS=600 # synth timeout (long chunked text)
|
||||
|
||||
# ── AI Insights — sibling services (optional) ───────────────────────────
|
||||
# Apollo (places, face inference, CLIP encoders). Single-Apollo deploys
|
||||
# typically set only APOLLO_API_BASE_URL and let the face + CLIP
|
||||
# clients fall back to it.
|
||||
# Apollo (places + face inference). Single Apollo deploys typically set
|
||||
# only APOLLO_API_BASE_URL and let the face client fall back to it.
|
||||
# APOLLO_API_BASE_URL=http://apollo.lan:8000
|
||||
# APOLLO_FACE_API_BASE_URL=http://apollo.lan:8000
|
||||
# APOLLO_CLIP_API_BASE_URL=http://apollo.lan:8000
|
||||
# SMS_API_URL=http://localhost:8000
|
||||
# SMS_API_TOKEN=
|
||||
|
||||
@@ -129,51 +80,6 @@ FACE_DETECT_TIMEOUT_SEC=60
|
||||
FACE_BACKLOG_MAX_PER_TICK=64
|
||||
FACE_HASH_BACKFILL_MAX_PER_TICK=2000
|
||||
|
||||
# ── CLIP semantic photo search ──────────────────────────────────────────
|
||||
# ImageApi calls Apollo's /api/internal/clip/{encode_image,encode_text}
|
||||
# to populate per-photo embeddings during the watcher's backlog drain
|
||||
# and to encode user queries at /photos/search time. Disabled when
|
||||
# neither APOLLO_CLIP_API_BASE_URL nor APOLLO_API_BASE_URL is set.
|
||||
#
|
||||
# Per-watcher-tick cap on the encode drain. Default 32 ≈ ~1 photo/sec
|
||||
# on CPU, ~30 photos/sec on a single-GPU host (Apollo's threadpool
|
||||
# is 1 on CUDA, so concurrency is bounded server-side regardless of
|
||||
# our setting). Bump on a fresh deploy to clear the backlog faster.
|
||||
CLIP_BACKLOG_MAX_PER_TICK=32
|
||||
# Client-side parallel encode calls per drain pass. Apollo's GPU pool
|
||||
# serializes server-side; this just overlaps file-IO with inference.
|
||||
CLIP_ENCODE_CONCURRENCY=4
|
||||
# Per-encode HTTP timeout. CPU-only Apollo deploys may need higher.
|
||||
CLIP_REQUEST_TIMEOUT_SEC=60
|
||||
|
||||
# ── RAG / search ────────────────────────────────────────────────────────
|
||||
# Set to `1` to enable cross-encoder reranking on /search results.
|
||||
SEARCH_RAG_RERANK=0
|
||||
|
||||
# ── Nightly reel pre-generation (Phase 3+) ──────────────────────────────
|
||||
# Set to `1` to enable the scheduler. Disabled by default.
|
||||
# REEL_PREGEN_ENABLED=1
|
||||
# Hour (0-23) when the nightly batch fires. Default 3 AM.
|
||||
# REEL_PREGEN_HOUR=3
|
||||
# Day of week for weekly reels (0=Sun, 1=Mon, …). Default Monday.
|
||||
# REEL_PREGEN_WEEK_DOW=1
|
||||
# Timezone offset in minutes from UTC (e.g., -480 = PST). Defaults to
|
||||
# the server's local timezone.
|
||||
# REEL_PREGEN_TZ_OFFSET_MINUTES=
|
||||
# Fixed timezone offset — overrides auto-detect to avoid DST shifts.
|
||||
# When set, both the DB fallback and env fallback use this value.
|
||||
# REEL_PREGEN_TZ_FIXED_MINUTES=-480
|
||||
# Voice ID for narration (e.g., "grandma"). Falls back to the value
|
||||
# stored in the user_ai_prefs DB row when set.
|
||||
# REEL_PREGEN_VOICE=
|
||||
# Library filter: a library id (e.g. "1") or "all" for every library.
|
||||
# REEL_PREGEN_LIBRARY=all
|
||||
# Max agentic tool iterations for pre-gen scripter. Default 8.
|
||||
# REEL_PREGEN_MAX_TOOL_ITERS=8
|
||||
#
|
||||
# On-disk reel cache sweep (runs every 24h, independent of pre-gen). Removes
|
||||
# reel MP4s with no ledger row + no live job that are older than the max age —
|
||||
# i.e. the on-demand cache, which otherwise grows forever. Set to 0 to disable.
|
||||
# REEL_CACHE_SWEEP_ENABLED=1
|
||||
# Age (days) before an unreferenced reel MP4 is swept. Default 7.
|
||||
# REEL_CACHE_MAX_AGE_DAYS=7
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Normalize line endings in the repo to LF. Windows checkouts can still
|
||||
# present working-copy files as CRLF; this just keeps the committed history
|
||||
# stable so contributors on any OS don't see whitespace-only diffs every
|
||||
# time someone touches a file.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Migrations and SQL must be LF — SQLite parsers don't care, but diffing
|
||||
# is much cleaner with stable endings.
|
||||
*.sql text eol=lf
|
||||
@@ -5,8 +5,6 @@ database/target
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
# Server-local TTS pronunciation overrides (tts_pronunciations.example.json is the template)
|
||||
/tts_pronunciations.json
|
||||
/tmp
|
||||
/docs
|
||||
/specs
|
||||
|
||||
@@ -473,16 +473,10 @@ GET /memories?path=...&recursive=true
|
||||
POST /insights/generate (non-agentic single-shot)
|
||||
POST /insights/generate/agentic (tool-calling loop; body: { file_path, backend?, model?, ... })
|
||||
GET /insights?path=...&library=...
|
||||
GET /insights/models (local-backend models + capabilities; Ollama OR llama-swap based on LLM_BACKEND)
|
||||
GET /insights/models (local Ollama models + capabilities)
|
||||
GET /insights/openrouter/models (curated OpenRouter allowlist)
|
||||
POST /insights/rate (thumbs up/down for training data)
|
||||
|
||||
// Text-to-Speech (Chatterbox via llama-swap; needs LLAMA_SWAP_URL)
|
||||
POST /tts/speech (read-aloud: { text, voice?, ... } -> { audio_base64, format })
|
||||
GET /tts/voices (Chatterbox voice library)
|
||||
POST /tts/voices/upload (clone a voice from an uploaded clip; multipart)
|
||||
POST /tts/voices/from-library (clone a voice from a library audio/video file)
|
||||
|
||||
// Insight Chat Continuation
|
||||
POST /insights/chat (single-turn reply, non-streaming)
|
||||
POST /insights/chat/stream (SSE: text / tool_call / tool_result / truncated / done)
|
||||
@@ -637,55 +631,8 @@ OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small # Optional, embeddings
|
||||
OPENROUTER_HTTP_REFERER=https://your-site.example # Optional attribution header
|
||||
OPENROUTER_APP_TITLE=ImageApi # Optional attribution header
|
||||
|
||||
# Local LLM backend switch. `ollama` (default) keeps the OLLAMA_* settings
|
||||
# above; `llamacpp` swaps the entire local stack (chat + vision describe +
|
||||
# embeddings) over to llama-swap. The switch is global and applies to
|
||||
# `backend=local` requests and to `backend=hybrid`'s describe pass (hybrid
|
||||
# chat still goes to OpenRouter). Don't flip mid-deploy without
|
||||
# re-embedding — mixed vector spaces break similarity search.
|
||||
LLM_BACKEND=ollama
|
||||
|
||||
# Embedding model contract. Corpus and queries must be embedded by the same
|
||||
# model with matching prefixes — after changing the embed model or any of
|
||||
# these, run `cargo run --bin reembed_embeddings` (all tables) or search is
|
||||
# garbage. Prefix values may contain a literal \n (expanded to a newline).
|
||||
EMBEDDING_DIM=768 # 768 = nomic-embed-text v1.5; 1024 = Qwen3-Embedding-0.6B
|
||||
EMBED_QUERY_PREFIX= # nomic: "search_query: " | Qwen3: "Instruct: <task>\nQuery: "
|
||||
EMBED_DOCUMENT_PREFIX= # nomic: "search_document: " | Qwen3: leave empty
|
||||
|
||||
# llama.cpp / llama-swap (used when LLM_BACKEND=llamacpp). OpenAI-compatible
|
||||
# proxy hosting one or more llama-server processes. Chat models receive
|
||||
# images directly via content-parts (all models assumed vision-capable).
|
||||
LLAMA_SWAP_URL=http://localhost:9292/v1 # Required when LLM_BACKEND=llamacpp
|
||||
LLAMA_SWAP_PRIMARY_MODEL=chat # Chat slot id (matches config.yaml)
|
||||
LLAMA_SWAP_VISION_MODEL= # Dedicated vision slot for describe_image / describe_photo
|
||||
# tool. Defaults to PRIMARY_MODEL when unset.
|
||||
LLAMA_SWAP_EMBEDDING_MODEL=embed # Embedding slot id
|
||||
LLAMA_SWAP_ALLOWED_MODELS=chat,coder # Curated allowlist surfaced by GET /insights/models
|
||||
# when LLM_BACKEND=llamacpp. All report has_vision=true.
|
||||
# Empty = picker shows only the configured primary model.
|
||||
LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS=180 # Per-request timeout; bump for slow CPU offload
|
||||
|
||||
# Text-to-speech (Chatterbox served behind llama-swap). Only needs
|
||||
# LLAMA_SWAP_URL — independent of LLM_BACKEND. Powers /tts/speech (read-aloud)
|
||||
# and /tts/voices* (voice cloning). Reference audio is ffmpeg-normalized to WAV
|
||||
# server-side, so any source format works.
|
||||
LLAMA_SWAP_TTS_MODEL=chatterbox # TTS model id in config.yaml (default: chatterbox)
|
||||
LLAMA_SWAP_TTS_VOICE=m # Default voice when /tts/speech omits one (optional)
|
||||
LLAMA_SWAP_TTS_REF_SECONDS=30 # Max voice-clone reference clip length, seconds
|
||||
# (Chatterbox is zero-shot; ~10-20s clean ref is ideal)
|
||||
LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS=600 # Per-request synth timeout (long chunked insights take
|
||||
# minutes); overrides the shared client timeout for /tts/speech
|
||||
TTS_PRONUNCIATIONS_PATH=tts_pronunciations.json # JSON map of pronunciation overrides applied before synth
|
||||
# (see tts_pronunciations.example.json); hot-reloaded on change
|
||||
|
||||
# Insight Chat Continuation
|
||||
AGENTIC_CHAT_MAX_ITERATIONS=6 # Cap on tool-calling iterations per chat turn (default 6)
|
||||
AGENTIC_CHAT_DEFAULT_NUM_CTX=32768 # Assumed context window for the history-truncation budget
|
||||
# when a chat request omits num_ctx (default 32768). Size to
|
||||
# the smallest context among the chat models actually served;
|
||||
# too small silently guts replayed history every turn (and
|
||||
# destroys llama.cpp KV-cache prefix reuse).
|
||||
```
|
||||
|
||||
**AI Insights Fallback Behavior:**
|
||||
@@ -703,50 +650,10 @@ The `OllamaClient` provides methods to query available models:
|
||||
|
||||
This allows runtime verification of model availability before generating insights.
|
||||
|
||||
**Local backend switch (`LLM_BACKEND`):**
|
||||
|
||||
One env var decides which "local" stack the server runs against — `ollama`
|
||||
(default) or `llamacpp`. It's global on purpose: chat, vision, and
|
||||
embeddings all route through the same backend, so the embedding-vector
|
||||
column in SQLite stays in one vector space. Don't flip mid-deploy without
|
||||
re-embedding the affected rows — similarity search will collapse.
|
||||
|
||||
- `LLM_BACKEND=ollama`: chat, vision, and embeddings use Ollama. Vision
|
||||
capability is probed per-model via `/api/show`.
|
||||
- `LLM_BACKEND=llamacpp`: chat models receive images directly via OpenAI
|
||||
content-parts (all models assumed vision-capable). Embeddings hit the
|
||||
`embed` slot. A dedicated `LLAMA_SWAP_VISION_MODEL` slot (defaults to
|
||||
the chat model) handles `describe_image` for the `describe_photo` tool.
|
||||
Requires `LLAMA_SWAP_URL`.
|
||||
|
||||
The per-request `backend=hybrid` override is orthogonal: it always sends
|
||||
chat to OpenRouter (text-only, images are pre-described and inlined), but
|
||||
the describe + embed passes still route through whichever `LLM_BACKEND`
|
||||
is configured.
|
||||
|
||||
**Backend dispatch (`ResolvedBackend`):**
|
||||
|
||||
`InsightGenerator::resolve_backend(kind, overrides)` is the single entry
|
||||
point that builds clients for a request. Returns a `ResolvedBackend` with
|
||||
two roles: `.chat()` (the agentic/chat client) and `.local()` (local-only
|
||||
utility calls: rerank, describe_image, embeddings). `BackendKind` is an
|
||||
enum (`Local` | `Hybrid`) replacing the stringly-typed `"local"` /
|
||||
`"hybrid"` labels. `SamplingOverrides` groups model/ctx/temp/top_p/top_k/
|
||||
min_p per-request overrides. All downstream code (`execute_tool`,
|
||||
`run_streaming_agentic_loop`, etc.) takes `&ResolvedBackend` rather than
|
||||
individual client references.
|
||||
|
||||
`GET /insights/models` returns the local-backend models with capabilities
|
||||
in the same envelope shape regardless of `LLM_BACKEND`: Ollama servers
|
||||
when `ollama`, llama-swap slots (from `LLAMA_SWAP_ALLOWED_MODELS`) when
|
||||
`llamacpp`. No `/insights/llamacpp/models` — the picker reads a single
|
||||
endpoint.
|
||||
|
||||
**Hybrid Backend (OpenRouter):**
|
||||
- Per-request opt-in via `backend=hybrid` on `POST /insights/generate/agentic`.
|
||||
- Vision describe happens before the agentic loop; the description is inlined
|
||||
into the chat prompt and the agentic loop runs on OpenRouter. Vision
|
||||
routes through whichever `LLM_BACKEND` is configured.
|
||||
- Local Ollama still describes the image (vision); the description is inlined
|
||||
into the chat prompt and the agentic loop runs on OpenRouter.
|
||||
- `request.model` (if provided) overrides `OPENROUTER_DEFAULT_MODEL` for that
|
||||
call. The mobile picker reads from `OPENROUTER_ALLOWED_MODELS`.
|
||||
- No live capability precheck — the operator-curated allowlist is trusted.
|
||||
@@ -754,15 +661,6 @@ endpoint.
|
||||
- `GET /insights/openrouter/models` returns `{ models, default_model, configured }`
|
||||
for client picker UIs.
|
||||
|
||||
**Cross-replay matrix (chat continuation):**
|
||||
- `local → local` allowed (whether served by Ollama or llama-swap; that's
|
||||
a deploy-time decision, not a request-time one).
|
||||
- `hybrid → hybrid` allowed.
|
||||
- `hybrid → local` allowed (the inlined description replays as text).
|
||||
- `local → hybrid` rejected — the stored transcript has raw images in the
|
||||
first user message and OpenRouter providers don't accept that shape
|
||||
consistently. Regenerate the insight in hybrid mode instead.
|
||||
|
||||
**Insight Chat Continuation:**
|
||||
|
||||
After an agentic insight is generated, the full `Vec<ChatMessage>` transcript is
|
||||
@@ -809,17 +707,14 @@ Per-`(library_id, file_path)` async mutex (`AppState.insight_chat.chat_locks`)
|
||||
serialises concurrent turns on the same insight so the JSON blob doesn't race.
|
||||
|
||||
Context management is a soft bound: if the serialized history exceeds
|
||||
`num_ctx - 2048` tokens (cheap 4-byte/token heuristic; `num_ctx` defaults
|
||||
to `AGENTIC_CHAT_DEFAULT_NUM_CTX`, 32768, when the request omits it), the
|
||||
oldest assistant-tool_call + tool_result pairs are dropped until under budget. The
|
||||
`num_ctx - 2048` tokens (cheap 4-byte/token heuristic), the oldest
|
||||
assistant-tool_call + tool_result pairs are dropped until under budget. The
|
||||
initial user message (with any images) and system prompt are always preserved.
|
||||
The `truncated` event / flag is surfaced to the client when a drop occurred.
|
||||
|
||||
Configurable env:
|
||||
- `AGENTIC_CHAT_MAX_ITERATIONS` — cap on tool-calling iterations per turn
|
||||
(default 6). Per-request `max_iterations` is clamped to this cap.
|
||||
- `AGENTIC_CHAT_DEFAULT_NUM_CTX` — assumed context window for the truncation
|
||||
budget when the request omits `num_ctx` (default 32768).
|
||||
|
||||
**Apollo Places integration (optional):**
|
||||
|
||||
|
||||
Generated
+1
-4
@@ -2051,7 +2051,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "image-api"
|
||||
version = "1.4.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"actix",
|
||||
"actix-cors",
|
||||
@@ -2104,7 +2104,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zerocopy",
|
||||
]
|
||||
@@ -4392,9 +4391,7 @@ version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "image-api"
|
||||
version = "1.4.0"
|
||||
version = "1.1.0"
|
||||
authors = ["Cameron Cordes <cameronc.dev@gmail.com>"]
|
||||
edition = "2024"
|
||||
|
||||
@@ -66,7 +66,6 @@ image_hasher = "3.0"
|
||||
bk-tree = "0.5"
|
||||
async-trait = "0.1"
|
||||
indicatif = "0.17"
|
||||
uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||
|
||||
# Windows lacks system sqlite3, so re-enable the bundled C build there.
|
||||
# Linux/macOS use the system library (faster builds, smaller binary).
|
||||
|
||||
@@ -147,56 +147,6 @@ so you can rewrite the saved summary from within chat.
|
||||
- `AGENTIC_CHAT_MAX_ITERATIONS` - Cap on tool-calling iterations per chat turn [default: `6`]
|
||||
- Per-request `max_iterations` (when sent by the client) is clamped to this cap
|
||||
|
||||
#### Text-to-Speech (Optional)
|
||||
Reads insights aloud and manages cloned voices via a Chatterbox model served
|
||||
behind the same llama-swap proxy. Only requires `LLAMA_SWAP_URL` (the TTS client
|
||||
is built whenever that's set — independent of `LLM_BACKEND`). Endpoints:
|
||||
- `POST /tts/speech` — body `{ text, voice?, format?, exaggeration?, cfg_weight?,
|
||||
temperature? }`; returns `{ audio_base64, format }`. Input is cleaned
|
||||
server-side (markdown + emoji stripped, then pronunciation overrides applied —
|
||||
see below) and the generation knobs are clamped
|
||||
to Chatterbox's ranges. Synthesis is serialized (one at a time — the upstream
|
||||
has no GPU lock of its own); a concurrent request gets a fast `429`.
|
||||
- `POST /tts/speech/jobs` — durable variant for long syntheses: same body as
|
||||
`/tts/speech`, returns `202 { job_id, status }` immediately. Jobs queue on the
|
||||
GPU permit instead of fast-failing `429`.
|
||||
- `GET /tts/speech/jobs/{id}` — poll a job: `{ job_id, status, format,
|
||||
audio_base64?, error? }` with status `queued|running|done|error|cancelled`.
|
||||
Results are kept in memory ~10 min after completion, then the job 404s.
|
||||
- `DELETE /tts/speech/jobs/{id}` — cancel a queued/running job.
|
||||
- `GET /tts/voices` — list the voice library. Served from an in-memory cache
|
||||
(so the listing doesn't make llama-swap spin up the TTS model and evict the
|
||||
resident LLM); pass `?refresh=1` to force an upstream re-query. The cache is
|
||||
invalidated by voice create/delete.
|
||||
- `POST /tts/voices/upload` — multipart `voice_name` + `voice_file`; clone a
|
||||
voice from an uploaded clip (≤25 MB).
|
||||
- `POST /tts/voices/from-library` — body `{ voice_name, path, library? }`; clone
|
||||
from a library file (audio forwarded as-is; video has its audio extracted via
|
||||
ffmpeg).
|
||||
- `DELETE /tts/voices/{name}` — remove a cloned voice from the library.
|
||||
|
||||
Created voice names are tagged with the ref-clip cap in effect (e.g.
|
||||
`grandma-30s`) so the library shows which reference length produced each clone.
|
||||
|
||||
Words the model mispronounces (place names, initialisms) can be rewritten
|
||||
before synthesis via a JSON map — copy `tts_pronunciations.example.json` to
|
||||
`tts_pronunciations.json` and edit; changes apply without a restart. Full
|
||||
matching rules are documented in `src/ai/pronunciation.rs`.
|
||||
|
||||
Env:
|
||||
- `TTS_PRONUNCIATIONS_PATH` - pronunciation-override JSON file
|
||||
[default: `tts_pronunciations.json` in the working directory]
|
||||
- `LLAMA_SWAP_TTS_MODEL` - TTS model id in llama-swap's `config.yaml` [default: `chatterbox`]
|
||||
- `LLAMA_SWAP_TTS_VOICE` - default voice used when a `/tts/speech` request omits `voice` (optional)
|
||||
- `LLAMA_SWAP_TTS_REF_SECONDS` - max voice-clone reference clip length in seconds
|
||||
[default: `30`]. Reference audio is ffmpeg-normalized to mono 24 kHz WAV (so any
|
||||
source format works); Chatterbox is zero-shot, so a clean ~10–20s sample is the
|
||||
sweet spot — more rarely helps.
|
||||
- `LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS` - per-request synthesis timeout in
|
||||
seconds [default: `600`]. Long insights are chunked + synthesized server-side
|
||||
and can take minutes; this is separate from (and overrides, for `/tts/speech`)
|
||||
the shared `LLAMA_SWAP_REQUEST_TIMEOUT_SECONDS`.
|
||||
|
||||
#### Fallback Behavior
|
||||
- Primary server is tried first with 5-second connection timeout
|
||||
- On failure, automatically falls back to secondary server (if configured)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_image_exif_clip_backfill;
|
||||
ALTER TABLE image_exif DROP COLUMN clip_model_version;
|
||||
ALTER TABLE image_exif DROP COLUMN clip_embedding;
|
||||
@@ -1,27 +0,0 @@
|
||||
-- CLIP semantic photo search: store a per-photo image embedding so
|
||||
-- text queries can rerank against the live library via cosine
|
||||
-- similarity. Apollo encodes the bytes via its CLIP service; ImageApi
|
||||
-- writes the resulting blob here.
|
||||
--
|
||||
-- `clip_embedding` is the raw little-endian float32 buffer of an
|
||||
-- L2-normalized vector (dim depends on the model — 768 bytes×4 for
|
||||
-- ViT-L/14, 512 bytes×4 for ViT-B/32). Apollo always returns the
|
||||
-- normalized form so the search-time dot product reduces to a plain
|
||||
-- cosine similarity.
|
||||
--
|
||||
-- `clip_model_version` echoes the upstream `APOLLO_CLIP_MODEL` (e.g.
|
||||
-- "ViT-L/14"). A model swap shouldn't silently mix geometries — the
|
||||
-- backfill drain will re-eligibilize rows whose stored model_version
|
||||
-- differs from the live engine's, and the search route refuses to
|
||||
-- mix rows from two model_versions in the same response.
|
||||
ALTER TABLE image_exif ADD COLUMN clip_embedding BLOB;
|
||||
ALTER TABLE image_exif ADD COLUMN clip_model_version TEXT;
|
||||
|
||||
-- Partial index for the backfill drain. Mirrors the shape of
|
||||
-- `idx_image_exif_date_backfill`: candidate rows are those with a
|
||||
-- known content_hash (so we don't race the unhashed backlog) but no
|
||||
-- embedding yet. SELECT cost stays O(missing rows) instead of full
|
||||
-- table scan once the column is mostly populated.
|
||||
CREATE INDEX IF NOT EXISTS idx_image_exif_clip_backfill
|
||||
ON image_exif (id)
|
||||
WHERE clip_embedding IS NULL AND content_hash IS NOT NULL;
|
||||
@@ -1,3 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_insight_gen_jobs_status_cleanup;
|
||||
DROP INDEX IF EXISTS idx_insight_gen_jobs_file;
|
||||
DROP TABLE IF EXISTS insight_generation_jobs;
|
||||
@@ -1,23 +0,0 @@
|
||||
-- Track async insight generation jobs so the client can poll for
|
||||
-- completion after the server returns 202 Accepted. Each generation
|
||||
-- creates a new row; the application layer cancels prior running
|
||||
-- jobs before inserting.
|
||||
CREATE TABLE insight_generation_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id INTEGER NOT NULL DEFAULT 1,
|
||||
file_path TEXT NOT NULL,
|
||||
generation_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
started_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
result_insight_id INTEGER,
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
-- For the status endpoint: fast lookup by (library_id, file_path)
|
||||
CREATE INDEX idx_insight_gen_jobs_file
|
||||
ON insight_generation_jobs(library_id, file_path);
|
||||
|
||||
-- For startup cleanup (future): prune old completed/failed jobs
|
||||
CREATE INDEX idx_insight_gen_jobs_status_cleanup
|
||||
ON insight_generation_jobs(status, started_at);
|
||||
@@ -1,28 +0,0 @@
|
||||
-- Restore UNIQUE constraint
|
||||
|
||||
CREATE TABLE insight_generation_jobs_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id INTEGER NOT NULL DEFAULT 1,
|
||||
file_path TEXT NOT NULL,
|
||||
generation_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
started_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
result_insight_id INTEGER,
|
||||
error_message TEXT,
|
||||
UNIQUE(library_id, file_path, generation_type)
|
||||
);
|
||||
|
||||
INSERT INTO insight_generation_jobs_new
|
||||
SELECT id, library_id, file_path, generation_type, status, started_at, completed_at, result_insight_id, error_message
|
||||
FROM insight_generation_jobs;
|
||||
|
||||
DROP TABLE insight_generation_jobs;
|
||||
|
||||
ALTER TABLE insight_generation_jobs_new RENAME TO insight_generation_jobs;
|
||||
|
||||
CREATE INDEX idx_insight_gen_jobs_file
|
||||
ON insight_generation_jobs(library_id, file_path);
|
||||
|
||||
CREATE INDEX idx_insight_gen_jobs_status_cleanup
|
||||
ON insight_generation_jobs(status, started_at);
|
||||
@@ -1,30 +0,0 @@
|
||||
-- Remove UNIQUE(library_id, file_path, generation_type) constraint to allow
|
||||
-- multiple job rows per file. This enables proper cancel/regenerate semantics:
|
||||
-- a new job is always inserted on regenerate, and the old job is cancelled
|
||||
-- independently. The application layer prevents concurrent running jobs.
|
||||
|
||||
CREATE TABLE insight_generation_jobs_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id INTEGER NOT NULL DEFAULT 1,
|
||||
file_path TEXT NOT NULL,
|
||||
generation_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
started_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
result_insight_id INTEGER,
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
INSERT INTO insight_generation_jobs_new
|
||||
SELECT id, library_id, file_path, generation_type, status, started_at, completed_at, result_insight_id, error_message
|
||||
FROM insight_generation_jobs;
|
||||
|
||||
DROP TABLE insight_generation_jobs;
|
||||
|
||||
ALTER TABLE insight_generation_jobs_new RENAME TO insight_generation_jobs;
|
||||
|
||||
CREATE INDEX idx_insight_gen_jobs_file
|
||||
ON insight_generation_jobs(library_id, file_path);
|
||||
|
||||
CREATE INDEX idx_insight_gen_jobs_status_cleanup
|
||||
ON insight_generation_jobs(status, started_at);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- SQLite doesn't support DROP COLUMN before 3.35.0; recreate the table
|
||||
-- without the new columns. This is only needed for rollback.
|
||||
CREATE TABLE photo_insights_old AS
|
||||
SELECT id, library_id, rel_path, title, summary, generated_at,
|
||||
model_version, is_current, training_messages, approved,
|
||||
backend, fewshot_source_ids, content_hash
|
||||
FROM photo_insights;
|
||||
|
||||
DROP TABLE photo_insights;
|
||||
|
||||
ALTER TABLE photo_insights_old RENAME TO photo_insights;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Persist generation parameters on each insight row for auditing.
|
||||
ALTER TABLE photo_insights ADD COLUMN num_ctx INTEGER;
|
||||
ALTER TABLE photo_insights ADD COLUMN temperature REAL;
|
||||
ALTER TABLE photo_insights ADD COLUMN top_p REAL;
|
||||
ALTER TABLE photo_insights ADD COLUMN top_k INTEGER;
|
||||
ALTER TABLE photo_insights ADD COLUMN min_p REAL;
|
||||
ALTER TABLE photo_insights ADD COLUMN system_prompt TEXT;
|
||||
ALTER TABLE photo_insights ADD COLUMN persona_id TEXT;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- SQLite doesn't support DROP COLUMN before 3.35.0; recreate the table
|
||||
-- without the token-count columns. This is only needed for rollback.
|
||||
CREATE TABLE photo_insights_old AS
|
||||
SELECT id, library_id, rel_path, title, summary, generated_at,
|
||||
model_version, is_current, training_messages, approved,
|
||||
backend, fewshot_source_ids, content_hash,
|
||||
num_ctx, temperature, top_p, top_k, min_p,
|
||||
system_prompt, persona_id
|
||||
FROM photo_insights;
|
||||
|
||||
DROP TABLE photo_insights;
|
||||
|
||||
ALTER TABLE photo_insights_old RENAME TO photo_insights;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Persist token usage on each insight row. Split from
|
||||
-- 2026-05-27-000002_add_insight_generation_params because that
|
||||
-- migration was already applied on some environments before these
|
||||
-- columns were added.
|
||||
ALTER TABLE photo_insights ADD COLUMN prompt_eval_count INTEGER;
|
||||
ALTER TABLE photo_insights ADD COLUMN eval_count INTEGER;
|
||||
@@ -1,2 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_precomputed_reels_span_library;
|
||||
DROP TABLE IF EXISTS precomputed_reels;
|
||||
@@ -1,14 +0,0 @@
|
||||
CREATE TABLE precomputed_reels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
span TEXT NOT NULL,
|
||||
library_key TEXT NOT NULL,
|
||||
cache_key TEXT NOT NULL,
|
||||
output_path TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
media_count INT NOT NULL,
|
||||
render_version INT NOT NULL DEFAULT 1,
|
||||
tz_offset_minutes INT NOT NULL,
|
||||
voice TEXT,
|
||||
generated_at BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_precomputed_reels_span_library ON precomputed_reels(span, library_key, generated_at DESC);
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS user_ai_prefs;
|
||||
@@ -1,7 +0,0 @@
|
||||
CREATE TABLE user_ai_prefs (
|
||||
id INTEGER PRIMARY KEY CHECK(id=1),
|
||||
voice TEXT,
|
||||
tz_offset_minutes INTEGER,
|
||||
library TEXT,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
@@ -1,146 +0,0 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use crate::ai::llm_client::LlmClient;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BackendKind {
|
||||
Local,
|
||||
Hybrid,
|
||||
}
|
||||
|
||||
impl BackendKind {
|
||||
pub fn parse(s: &str) -> Result<Self> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"local" | "" => Ok(Self::Local),
|
||||
"hybrid" => Ok(Self::Hybrid),
|
||||
other => Err(anyhow!(
|
||||
"unknown backend '{}'; expected 'local' or 'hybrid'",
|
||||
other
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Local => "local",
|
||||
Self::Hybrid => "hybrid",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BackendKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SamplingOverrides {
|
||||
pub model: Option<String>,
|
||||
pub num_ctx: Option<i32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub top_p: Option<f32>,
|
||||
pub top_k: Option<i32>,
|
||||
pub min_p: Option<f32>,
|
||||
/// Reasoning toggle. Only the llama.cpp backend honors it (forwarded as
|
||||
/// `chat_template_kwargs.enable_thinking`); other backends ignore it.
|
||||
/// `None` leaves the model/template default in place.
|
||||
pub enable_thinking: Option<bool>,
|
||||
}
|
||||
|
||||
impl SamplingOverrides {
|
||||
pub fn has_sampling(&self) -> bool {
|
||||
self.temperature.is_some()
|
||||
|| self.top_p.is_some()
|
||||
|| self.top_k.is_some()
|
||||
|| self.min_p.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResolvedBackend {
|
||||
chat: Box<dyn LlmClient>,
|
||||
local: Box<dyn LlmClient>,
|
||||
pub kind: BackendKind,
|
||||
/// `true` when the chat model receives images directly (Ollama with
|
||||
/// vision, or llamacpp). `false` for hybrid where we describe-then-inline.
|
||||
pub images_inline: bool,
|
||||
}
|
||||
|
||||
impl ResolvedBackend {
|
||||
pub fn new(
|
||||
chat: Box<dyn LlmClient>,
|
||||
local: Box<dyn LlmClient>,
|
||||
kind: BackendKind,
|
||||
images_inline: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
chat,
|
||||
local,
|
||||
kind,
|
||||
images_inline,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chat(&self) -> &dyn LlmClient {
|
||||
self.chat.as_ref()
|
||||
}
|
||||
|
||||
pub fn local(&self) -> &dyn LlmClient {
|
||||
self.local.as_ref()
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &str {
|
||||
self.chat.primary_model()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_backend_kind() {
|
||||
assert_eq!(BackendKind::parse("local").unwrap(), BackendKind::Local);
|
||||
assert_eq!(BackendKind::parse("hybrid").unwrap(), BackendKind::Hybrid);
|
||||
assert_eq!(BackendKind::parse(" Local ").unwrap(), BackendKind::Local);
|
||||
assert_eq!(BackendKind::parse("HYBRID").unwrap(), BackendKind::Hybrid);
|
||||
assert_eq!(BackendKind::parse("").unwrap(), BackendKind::Local);
|
||||
assert!(BackendKind::parse("vllm").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_kind_as_str_roundtrips() {
|
||||
assert_eq!(
|
||||
BackendKind::parse(BackendKind::Local.as_str()).unwrap(),
|
||||
BackendKind::Local
|
||||
);
|
||||
assert_eq!(
|
||||
BackendKind::parse(BackendKind::Hybrid.as_str()).unwrap(),
|
||||
BackendKind::Hybrid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_overrides_has_sampling() {
|
||||
let empty = SamplingOverrides {
|
||||
model: None,
|
||||
num_ctx: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
top_k: None,
|
||||
min_p: None,
|
||||
enable_thinking: None,
|
||||
};
|
||||
assert!(!empty.has_sampling());
|
||||
|
||||
let with_temp = SamplingOverrides {
|
||||
model: None,
|
||||
num_ctx: Some(4096),
|
||||
temperature: Some(0.7),
|
||||
top_p: None,
|
||||
top_k: None,
|
||||
min_p: None,
|
||||
enable_thinking: None,
|
||||
};
|
||||
assert!(with_temp.has_sampling());
|
||||
}
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
//! Thin async HTTP client for Apollo's `/api/internal/clip/*` endpoints.
|
||||
//!
|
||||
//! Apollo hosts the OpenAI CLIP inference service (ViT-L/14 by default,
|
||||
//! configurable via `APOLLO_CLIP_MODEL`). This client is the ImageApi side
|
||||
//! of the contract: shove image bytes through `/encode_image` to populate
|
||||
//! `image_exif.clip_embedding` during backfill, and call `/encode_text` to
|
||||
//! encode a user's natural-language query at search time. The actual
|
||||
//! cosine-similarity rerank runs locally in ImageApi.
|
||||
//!
|
||||
//! Mirrors `face_client.rs` / `tag_client.rs` shape: optional base URL
|
||||
//! (None = disabled — feature off, drain and search no-op), reqwest
|
||||
//! client with a generous timeout because GPU inference under a backlog
|
||||
//! can queue server-side (Apollo's threadpool is bounded to 1 worker on
|
||||
//! CUDA).
|
||||
//!
|
||||
//! Configured via `APOLLO_CLIP_API_BASE_URL`, falling back to
|
||||
//! `APOLLO_API_BASE_URL` when the dedicated var is unset (single-Apollo
|
||||
//! deploys are the common case).
|
||||
//!
|
||||
//! Wire format:
|
||||
//! - `/encode_image`: multipart/form-data with `file=<bytes>` and
|
||||
//! `meta=<json>` (content_hash / library_id / rel_path for logging).
|
||||
//! - `/encode_text`: JSON `{"text": "<query>"}`.
|
||||
//!
|
||||
//! Both return `{model_version, embedding_dim, duration_ms, embedding}`
|
||||
//! where `embedding` is base64 of `dim×4` little-endian float32 bytes,
|
||||
//! L2-normalized so the rerank reduces to a plain dot product.
|
||||
//!
|
||||
//! Error mapping (reflected in [`ClipError`]):
|
||||
//! - 422 `decode_failed` / `empty_text` → permanent: ImageApi marks the
|
||||
//! row failed or surfaces the empty-query error to the search caller.
|
||||
//! - 503 `cuda_oom` / `engine_unavailable` → defer-and-retry: no marker.
|
||||
//! - Any other 5xx / network error → defer.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct EncodeImageMeta {
|
||||
pub content_hash: String,
|
||||
pub library_id: i32,
|
||||
pub rel_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)] // duration_ms logged by the backfill drain
|
||||
pub struct EncodeResponse {
|
||||
pub model_version: String,
|
||||
pub embedding_dim: i32,
|
||||
pub duration_ms: i64,
|
||||
/// base64 of `embedding_dim * 4` bytes (LE float32). ImageApi stores
|
||||
/// the decoded bytes verbatim as a BLOB.
|
||||
pub embedding: String,
|
||||
}
|
||||
|
||||
impl EncodeResponse {
|
||||
/// Decode the wire-format embedding back into raw bytes for storage.
|
||||
/// Validates the buffer is `embedding_dim * 4` bytes long so a
|
||||
/// malformed response surfaces here rather than as a downstream
|
||||
/// silent length mismatch.
|
||||
pub fn decode_embedding(&self) -> Result<Vec<u8>> {
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(self.embedding.as_bytes())
|
||||
.context("clip embedding base64 decode")?;
|
||||
let expected = (self.embedding_dim as usize) * 4;
|
||||
if bytes.len() != expected {
|
||||
anyhow::bail!(
|
||||
"clip embedding wrong size: got {} bytes, expected {} ({} * 4)",
|
||||
bytes.len(),
|
||||
expected,
|
||||
self.embedding_dim
|
||||
);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)] // load_error consumed by future health probe
|
||||
pub struct ClipHealth {
|
||||
pub loaded: bool,
|
||||
pub device: String,
|
||||
pub model_version: String,
|
||||
pub embedding_dim: i32,
|
||||
#[serde(default)]
|
||||
pub load_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClipError {
|
||||
/// Apollo refused for a reason that won't change on retry (decode
|
||||
/// failure on /encode_image, empty text on /encode_text).
|
||||
Permanent(anyhow::Error),
|
||||
/// Apollo couldn't process this turn but might next time (CUDA OOM,
|
||||
/// engine not loaded, network hiccup).
|
||||
Transient(anyhow::Error),
|
||||
/// Feature is disabled (no `APOLLO_CLIP_API_BASE_URL` /
|
||||
/// `APOLLO_API_BASE_URL`).
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ClipError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ClipError::Permanent(e) => write!(f, "permanent: {e}"),
|
||||
ClipError::Transient(e) => write!(f, "transient: {e}"),
|
||||
ClipError::Disabled => write!(f, "clip client disabled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClipError {}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClipClient {
|
||||
client: Client,
|
||||
base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl ClipClient {
|
||||
pub fn new(base_url: Option<String>) -> Self {
|
||||
let timeout_secs = std::env::var("CLIP_REQUEST_TIMEOUT_SEC")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(60);
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.expect("reqwest client build");
|
||||
Self {
|
||||
client,
|
||||
base_url: base_url.map(|u| u.trim_end_matches('/').to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read both standard env vars. `APOLLO_CLIP_API_BASE_URL` wins;
|
||||
/// fallback to `APOLLO_API_BASE_URL`. Both unset → disabled.
|
||||
pub fn from_env() -> Self {
|
||||
let base = std::env::var("APOLLO_CLIP_API_BASE_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("APOLLO_API_BASE_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
});
|
||||
Self::new(base)
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.base_url.is_some()
|
||||
}
|
||||
|
||||
/// Encode an image to a 768-d (ViT-L/14) or 512-d (ViT-B/32)
|
||||
/// L2-normalized embedding. Used by the backfill drain.
|
||||
pub async fn encode_image(
|
||||
&self,
|
||||
bytes: Vec<u8>,
|
||||
meta: EncodeImageMeta,
|
||||
) -> std::result::Result<EncodeResponse, ClipError> {
|
||||
let Some(base) = self.base_url.as_deref() else {
|
||||
return Err(ClipError::Disabled);
|
||||
};
|
||||
let url = format!("{}/api/internal/clip/encode_image", base);
|
||||
let meta_json = serde_json::to_string(&meta)
|
||||
.map_err(|e| ClipError::Permanent(anyhow::anyhow!("meta serialize: {e}")))?;
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("meta", meta_json)
|
||||
.part(
|
||||
"file",
|
||||
reqwest::multipart::Part::bytes(bytes)
|
||||
.file_name(meta.rel_path.clone())
|
||||
.mime_str("application/octet-stream")
|
||||
.unwrap_or_else(|_| reqwest::multipart::Part::bytes(Vec::new())),
|
||||
);
|
||||
self.send_multipart(&url, form).await
|
||||
}
|
||||
|
||||
/// Encode a natural-language query to an embedding. Used by the
|
||||
/// search route to rank stored image embeddings by cosine sim.
|
||||
pub async fn encode_text(&self, text: &str) -> std::result::Result<EncodeResponse, ClipError> {
|
||||
let Some(base) = self.base_url.as_deref() else {
|
||||
return Err(ClipError::Disabled);
|
||||
};
|
||||
let url = format!("{}/api/internal/clip/encode_text", base);
|
||||
let body = serde_json::json!({ "text": text });
|
||||
|
||||
let resp = match self.client.post(&url).json(&body).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.is_timeout() || e.is_connect() => {
|
||||
log::warn!("clip encode_text network error to {url}: {e}");
|
||||
return Err(ClipError::Transient(anyhow::anyhow!(
|
||||
"clip client network: {e}"
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("clip encode_text request error to {url}: {e}");
|
||||
return Err(ClipError::Transient(anyhow::anyhow!(
|
||||
"clip client request: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
let body: EncodeResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ClipError::Transient(anyhow::anyhow!("clip response decode: {e}")))?;
|
||||
return Ok(body);
|
||||
}
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
log::warn!("clip encode_text HTTP {status} from {url}: {body_text}");
|
||||
Err(classify_error_response(status.as_u16(), &body_text))
|
||||
}
|
||||
|
||||
/// Engine reachability + device/model report. Used as a startup
|
||||
/// sanity check from the probe binary and (later) the backlog drain.
|
||||
#[allow(dead_code)] // consumed by probe + drain
|
||||
pub async fn health(&self) -> Result<ClipHealth> {
|
||||
let base = self.base_url.as_deref().context("clip client disabled")?;
|
||||
let url = format!("{}/api/internal/clip/health", base);
|
||||
let resp = self.client.get(&url).send().await?.error_for_status()?;
|
||||
let body: ClipHealth = resp.json().await?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn send_multipart(
|
||||
&self,
|
||||
url: &str,
|
||||
form: reqwest::multipart::Form,
|
||||
) -> std::result::Result<EncodeResponse, ClipError> {
|
||||
let resp = match self.client.post(url).multipart(form).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.is_timeout() || e.is_connect() => {
|
||||
return Err(ClipError::Transient(anyhow::anyhow!(
|
||||
"clip client network: {e}"
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(ClipError::Transient(anyhow::anyhow!(
|
||||
"clip client request: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
let body: EncodeResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ClipError::Transient(anyhow::anyhow!("clip response decode: {e}")))?;
|
||||
return Ok(body);
|
||||
}
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
Err(classify_error_response(status.as_u16(), &body_text))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulled out as a pure function so the marker-row contract is unit-
|
||||
/// testable without spinning up an HTTP server. Matches the shape used
|
||||
/// by face_client::classify_error_response so future retry policies
|
||||
/// can share code.
|
||||
fn classify_error_response(status: u16, body_text: &str) -> ClipError {
|
||||
let detail_code = serde_json::from_str::<serde_json::Value>(body_text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("detail")
|
||||
.and_then(|d| d.as_str().map(str::to_string))
|
||||
.or_else(|| {
|
||||
v.get("detail")
|
||||
.and_then(|d| d.get("code"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if status == 422 {
|
||||
return ClipError::Permanent(anyhow::anyhow!(
|
||||
"clip {} {}: {}",
|
||||
status,
|
||||
detail_code,
|
||||
body_text
|
||||
));
|
||||
}
|
||||
if status == 503 {
|
||||
return ClipError::Transient(anyhow::anyhow!(
|
||||
"clip {} {}: {}",
|
||||
status,
|
||||
detail_code,
|
||||
body_text
|
||||
));
|
||||
}
|
||||
// 408 / 413 / 429 are operator-fixable infra issues; defer.
|
||||
if matches!(status, 408 | 413 | 429) {
|
||||
return ClipError::Transient(anyhow::anyhow!(
|
||||
"clip {} {}: {}",
|
||||
status,
|
||||
detail_code,
|
||||
body_text
|
||||
));
|
||||
}
|
||||
if (400..500).contains(&status) {
|
||||
ClipError::Permanent(anyhow::anyhow!(
|
||||
"clip {} {}: {}",
|
||||
status,
|
||||
detail_code,
|
||||
body_text
|
||||
))
|
||||
} else {
|
||||
ClipError::Transient(anyhow::anyhow!(
|
||||
"clip {} {}: {}",
|
||||
status,
|
||||
detail_code,
|
||||
body_text
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn is_permanent(e: &ClipError) -> bool {
|
||||
matches!(e, ClipError::Permanent(_))
|
||||
}
|
||||
fn is_transient(e: &ClipError) -> bool {
|
||||
matches!(e, ClipError::Transient(_))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_422_decode_failed_is_permanent() {
|
||||
assert!(is_permanent(&classify_error_response(
|
||||
422,
|
||||
r#"{"detail":"decode_failed: bad bytes"}"#
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_422_empty_text_is_permanent() {
|
||||
assert!(is_permanent(&classify_error_response(
|
||||
422,
|
||||
r#"{"detail":"empty_text"}"#
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_503_cuda_oom_is_transient() {
|
||||
assert!(is_transient(&classify_error_response(
|
||||
503,
|
||||
r#"{"detail":{"code":"cuda_oom","error":"out of memory"}}"#,
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_5xx_is_transient_other_4xx_is_permanent() {
|
||||
assert!(is_transient(&classify_error_response(500, "")));
|
||||
assert!(is_permanent(&classify_error_response(404, "{}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_infra_4xx_is_transient() {
|
||||
assert!(is_transient(&classify_error_response(408, "")));
|
||||
assert!(is_transient(&classify_error_response(413, "<html>")));
|
||||
assert!(is_transient(&classify_error_response(429, "{}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_embedding_size_mismatch_errors() {
|
||||
// dim=4 says we expect 16 bytes (4 floats × 4 bytes). Encode 8.
|
||||
use base64::Engine;
|
||||
let resp = EncodeResponse {
|
||||
model_version: "ViT-L/14".into(),
|
||||
embedding_dim: 4,
|
||||
duration_ms: 0,
|
||||
embedding: base64::engine::general_purpose::STANDARD.encode([0u8; 8]),
|
||||
};
|
||||
assert!(resp.decode_embedding().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_embedding_round_trip() {
|
||||
use base64::Engine;
|
||||
let bytes: Vec<u8> = (0..16).collect();
|
||||
let resp = EncodeResponse {
|
||||
model_version: "ViT-L/14".into(),
|
||||
embedding_dim: 4,
|
||||
duration_ms: 0,
|
||||
embedding: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
};
|
||||
assert_eq!(resp.decode_embedding().unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
// GPU lease — in-process coordination for llama-swap model contention.
|
||||
//
|
||||
// llama-swap runs the heavyweight models (chat / vision / Chatterbox TTS) as
|
||||
// a mutually-exclusive set on one GPU (matrix DSL `(q27 | … | tts) & e`): a
|
||||
// request for a non-resident model is HELD by llama-swap until the resident
|
||||
// model's in-flight requests drain, then the models swap. That hold counts
|
||||
// against the *holder's* reqwest timeout — measured live: a queued TTS burned
|
||||
// 77s of its budget behind a single LLM turn, and an LLM request behind a
|
||||
// running synthesis waited the entire remaining synth. Uncoordinated
|
||||
// cross-model traffic therefore times out instead of queueing.
|
||||
//
|
||||
// The lease moves that wait into this process, BEFORE the HTTP request is
|
||||
// sent and before its timeout starts:
|
||||
// - chat/vision requests (the LLM-side slots) share the READ lease;
|
||||
// - TTS synthesis and voice-library ops (anything that spins Chatterbox up
|
||||
// and evicts the LLM) take the WRITE lease;
|
||||
// - embeddings take NO lease: the `embed` slot is in llama-swap's
|
||||
// always-resident group (the `& e` term) and never participates in a swap,
|
||||
// so leasing it would only stall searches behind a queued synthesis.
|
||||
//
|
||||
// tokio's RwLock is fair (FIFO, write-preferring): a queued TTS gets the GPU
|
||||
// right after the current LLM request drains, and later LLM requests queue
|
||||
// behind it — bounded waits in both directions, no starvation, no timeout
|
||||
// budget burned while waiting.
|
||||
//
|
||||
// RULES: hold a lease for exactly one HTTP request (for streaming, the
|
||||
// stream's lifetime) and NEVER acquire one while already holding one — once a
|
||||
// writer is queued, new read acquisitions block, so nested acquisition can
|
||||
// deadlock.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
|
||||
static GPU_LEASE: LazyLock<RwLock<()>> = LazyLock::new(|| RwLock::new(()));
|
||||
|
||||
/// Waits longer than this are logged — they mean a cross-model swap was
|
||||
/// avoided and quantify what the request *would* have burned of its timeout.
|
||||
const SLOW_WAIT_LOG_SECS: f64 = 2.0;
|
||||
|
||||
/// Shared lease for LLM-side requests (chat / vision slots).
|
||||
pub async fn llm_lease() -> RwLockReadGuard<'static, ()> {
|
||||
let started = Instant::now();
|
||||
let guard = GPU_LEASE.read().await;
|
||||
log_slow_wait("llm", started);
|
||||
guard
|
||||
}
|
||||
|
||||
/// Exclusive lease for TTS-side requests (speech synthesis + voice-library
|
||||
/// ops that spin up Chatterbox).
|
||||
pub async fn tts_lease() -> RwLockWriteGuard<'static, ()> {
|
||||
let started = Instant::now();
|
||||
let guard = GPU_LEASE.write().await;
|
||||
log_slow_wait("tts", started);
|
||||
guard
|
||||
}
|
||||
|
||||
fn log_slow_wait(kind: &str, started: Instant) {
|
||||
let waited = started.elapsed().as_secs_f64();
|
||||
if waited > SLOW_WAIT_LOG_SECS {
|
||||
log::info!("GPU lease ({kind}): waited {waited:.1}s for the other model class to drain");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// One sequential test, not several: the lease is a single global, so
|
||||
// parallel tests interleaving reads and writes on it can hit the very
|
||||
// nested-acquisition deadlock the module comment warns about.
|
||||
#[tokio::test]
|
||||
async fn write_lease_excludes_readers_then_reads_share() {
|
||||
let w = tts_lease().await;
|
||||
// A reader must not acquire while the writer is held.
|
||||
let pending = tokio::spawn(async { drop(llm_lease().await) });
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!pending.is_finished());
|
||||
drop(w);
|
||||
pending.await.expect("reader acquires after writer drops");
|
||||
|
||||
// With no writer queued, read leases are shared.
|
||||
let a = llm_lease().await;
|
||||
let b = llm_lease().await;
|
||||
drop(a);
|
||||
drop(b);
|
||||
}
|
||||
}
|
||||
+200
-1238
File diff suppressed because it is too large
Load Diff
+290
-1080
File diff suppressed because it is too large
Load Diff
+375
-963
File diff suppressed because it is too large
Load Diff
-1444
File diff suppressed because it is too large
Load Diff
@@ -170,55 +170,3 @@ pub struct ModelCapabilities {
|
||||
pub has_vision: bool,
|
||||
pub has_tool_calling: bool,
|
||||
}
|
||||
|
||||
/// Strip a leading `<think>…</think>` reasoning block from model output.
|
||||
///
|
||||
/// Thinking models sometimes emit chain-of-thought inside think tags before
|
||||
/// the real answer. Everything after the first `</think>` is the answer;
|
||||
/// when no tag is present — or the text after it is empty — the trimmed
|
||||
/// input is returned unchanged. Mirrors the behavior Ollama's
|
||||
/// `extract_final_answer` has applied to single-shot generation; shared here
|
||||
/// so the tool-calling final-content paths (agentic generation + chat) can
|
||||
/// apply the identical cleanup before parsing / persisting.
|
||||
pub fn strip_think_blocks(response: &str) -> String {
|
||||
let response = response.trim();
|
||||
|
||||
if let Some(pos) = response.find("</think>") {
|
||||
let answer = response[pos + "</think>".len()..].trim();
|
||||
if !answer.is_empty() {
|
||||
return answer.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
response.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_think_blocks_removes_leading_think_block() {
|
||||
let raw = "<think>\nLet me reason about this.\n</think>\n\nTitle: A Day Out\n\nThe body.";
|
||||
assert_eq!(strip_think_blocks(raw), "Title: A Day Out\n\nThe body.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_blocks_passes_through_plain_content() {
|
||||
assert_eq!(strip_think_blocks(" just an answer "), "just an answer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_blocks_keeps_content_when_answer_after_tag_is_empty() {
|
||||
// A think block with nothing after it: better to return the trimmed
|
||||
// original than an empty string (matches Ollama's fallback).
|
||||
let raw = "<think>only thoughts</think>";
|
||||
assert_eq!(strip_think_blocks(raw), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_blocks_handles_unclosed_tag() {
|
||||
let raw = "<think>thinking forever";
|
||||
assert_eq!(strip_think_blocks(raw), raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
//! Bundle of the local LLM pair (Ollama + optional llama-swap) with the
|
||||
//! `LLM_BACKEND` dispatch baked in.
|
||||
//!
|
||||
//! Exists because passing the pair around as loose values invited the same
|
||||
//! bug three times: import/backfill tooling embedded corpora via
|
||||
//! `OllamaClient` directly while the query side dispatched through
|
||||
//! `embed_one`, so flipping `LLM_BACKEND=llamacpp` silently split queries
|
||||
//! and corpus into different vector spaces. Anything that writes or reads
|
||||
//! embeddings should go through this type (or `embed_one`/`embed_many`),
|
||||
//! never a concrete client.
|
||||
//!
|
||||
//! Deliberately knows nothing about chat policy — hybrid/OpenRouter routing
|
||||
//! is request-scoped and stays in `ResolvedBackend`. This is only the
|
||||
//! local stack: embeddings and offline single-shot generation.
|
||||
|
||||
// Constructed by binaries, not the server — dead code from main.rs's view.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::llamacpp::LlamaCppClient;
|
||||
use super::llm_client::LlmClient;
|
||||
use super::ollama::{EMBEDDING_MODEL, OllamaClient};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalLlm {
|
||||
ollama: OllamaClient,
|
||||
llamacpp: Option<Arc<LlamaCppClient>>,
|
||||
}
|
||||
|
||||
impl LocalLlm {
|
||||
pub fn new(ollama: OllamaClient, llamacpp: Option<Arc<LlamaCppClient>>) -> Self {
|
||||
Self { ollama, llamacpp }
|
||||
}
|
||||
|
||||
/// Construct from the canonical env wiring shared with `AppState`.
|
||||
pub fn from_env() -> Self {
|
||||
Self::new(
|
||||
crate::state::build_ollama_from_env(),
|
||||
crate::state::build_llamacpp_from_env(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Embed a search query (applies `EMBED_QUERY_PREFIX`). Callers must
|
||||
/// pick query vs document — retrieval models treat the two sides
|
||||
/// differently and an unmarked embed invites prefix-mismatch bugs.
|
||||
pub async fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
|
||||
super::embed_query(&self.ollama, self.llamacpp.as_deref(), text).await
|
||||
}
|
||||
|
||||
/// Embed corpus text (applies `EMBED_DOCUMENT_PREFIX`).
|
||||
pub async fn embed_document(&self, text: &str) -> Result<Vec<f32>> {
|
||||
super::embed_document(&self.ollama, self.llamacpp.as_deref(), text).await
|
||||
}
|
||||
|
||||
/// Single-shot local text generation via the `LLM_BACKEND`-selected
|
||||
/// client (offline tooling; chat turns belong to `ResolvedBackend`).
|
||||
pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result<String> {
|
||||
if super::local_backend_is_llamacpp() {
|
||||
if let Some(lc) = self.llamacpp.as_deref() {
|
||||
return <LlamaCppClient as LlmClient>::generate(lc, prompt, system, None).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \
|
||||
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama"
|
||||
);
|
||||
}
|
||||
self.ollama.generate(prompt, system).await
|
||||
}
|
||||
|
||||
/// Label identifying which backend + model produces embeddings right
|
||||
/// now. Store it alongside vectors (`model_version` columns) so a
|
||||
/// backend flip is detectable in the data, not just in env history.
|
||||
pub fn embedding_model_version(&self) -> String {
|
||||
if super::local_backend_is_llamacpp() {
|
||||
let slot = self
|
||||
.llamacpp
|
||||
.as_deref()
|
||||
.map(|c| c.embedding_model.as_str())
|
||||
.unwrap_or("embed");
|
||||
format!("llama-swap:{}", slot)
|
||||
} else {
|
||||
EMBEDDING_MODEL.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-174
@@ -1,22 +1,14 @@
|
||||
pub mod apollo_client;
|
||||
pub mod backend;
|
||||
pub mod clip_client;
|
||||
pub mod daily_summary_job;
|
||||
pub mod face_client;
|
||||
pub mod gpu;
|
||||
pub mod handlers;
|
||||
pub mod insight_chat;
|
||||
pub mod insight_generator;
|
||||
pub mod llamacpp;
|
||||
pub mod llm_client;
|
||||
pub mod local_llm;
|
||||
pub mod nl_query;
|
||||
pub mod ollama;
|
||||
pub mod openrouter;
|
||||
pub mod pronunciation;
|
||||
pub mod sms_client;
|
||||
pub mod tts;
|
||||
pub mod turn_registry;
|
||||
pub mod tag_client;
|
||||
|
||||
// strip_summary_boilerplate is used by binaries (test_daily_summary), not the library
|
||||
#[allow(unused_imports)]
|
||||
@@ -25,29 +17,18 @@ pub use daily_summary_job::{
|
||||
generate_daily_summaries, strip_summary_boilerplate,
|
||||
};
|
||||
pub use handlers::{
|
||||
cancel_generation_handler, cancel_turn_handler, chat_history_handler, chat_rewind_handler,
|
||||
chat_stream_handler, chat_turn_handler, delete_insight_handler, export_training_data_handler,
|
||||
generate_agentic_insight_handler, generate_insight_handler, generation_status_handler,
|
||||
get_all_insights_handler, get_available_models_handler, get_insight_handler,
|
||||
get_insight_history_handler, get_openrouter_models_handler, rate_insight_handler,
|
||||
turn_async_handler, turn_replay_handler,
|
||||
chat_history_handler, chat_rewind_handler, chat_stream_handler, chat_turn_handler,
|
||||
delete_insight_handler, export_training_data_handler, generate_agentic_insight_handler,
|
||||
generate_insight_handler, get_all_insights_handler, get_available_models_handler,
|
||||
get_insight_handler, get_openrouter_models_handler, rate_insight_handler,
|
||||
};
|
||||
pub use insight_generator::InsightGenerator;
|
||||
pub use llamacpp::LlamaCppClient;
|
||||
#[allow(unused_imports)]
|
||||
pub use llm_client::{
|
||||
ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction,
|
||||
};
|
||||
// LocalLlm is constructed by binaries (reembed_embeddings, importers), not the server
|
||||
#[allow(unused_imports)]
|
||||
pub use local_llm::LocalLlm;
|
||||
pub use ollama::{EMBEDDING_MODEL, OllamaClient};
|
||||
pub use sms_client::{SmsApiClient, SmsMessage};
|
||||
pub use tts::{
|
||||
cancel_speech_job_handler, create_speech_job_handler, create_voice_from_library_handler,
|
||||
create_voice_upload_handler, delete_voice_handler, list_voices_handler,
|
||||
speech_job_status_handler, tts_speech_handler,
|
||||
};
|
||||
|
||||
/// Display name used for the user in message transcripts and first-person
|
||||
/// prompt text. Reads the `USER_NAME` env var; defaults to `"Me"`. Models
|
||||
@@ -57,153 +38,3 @@ pub use tts::{
|
||||
pub fn user_display_name() -> String {
|
||||
std::env::var("USER_NAME").unwrap_or_else(|_| "Me".to_string())
|
||||
}
|
||||
|
||||
/// One switch for the "local" LLM stack: when `LLM_BACKEND=llamacpp` is
|
||||
/// set, chat / vision describe / embeddings all route through llama-swap
|
||||
/// instead of Ollama. Any other value (including unset, the default) is
|
||||
/// Ollama. This is intentionally global — embeddings must be drawn from
|
||||
/// a single source or similarity search across the index breaks (mixed
|
||||
/// vector spaces, possibly mixed dims). The `backend=hybrid` per-request
|
||||
/// override remains orthogonal: it always sends chat to OpenRouter, and
|
||||
/// uses `LLM_BACKEND` for the describe-then-inline vision pass.
|
||||
pub fn local_backend_is_llamacpp() -> bool {
|
||||
matches!(
|
||||
std::env::var("LLM_BACKEND")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.as_deref(),
|
||||
Some("llamacpp")
|
||||
)
|
||||
}
|
||||
|
||||
/// Expected embedding dimensionality, env-overridable via `EMBEDDING_DIM`
|
||||
/// (default 768, nomic-embed-text). Every store/query dim check reads this —
|
||||
/// swapping to a different-dim model (e.g. Qwen3-Embedding-0.6B at 1024) is
|
||||
/// then a config flip plus a `reembed_embeddings` run, not a code change.
|
||||
/// Cached for the process lifetime; a flip requires a restart anyway since
|
||||
/// the corpus must be re-embedded with it.
|
||||
pub fn embedding_dim() -> usize {
|
||||
static DIM: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||
*DIM.get_or_init(|| {
|
||||
std::env::var("EMBEDDING_DIM")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(768)
|
||||
})
|
||||
}
|
||||
|
||||
/// Read an embedding prefix from the environment. `.env` values can't hold
|
||||
/// real newlines, so a literal `\n` in the value is expanded — Qwen3-style
|
||||
/// query instructions need one ("Instruct: ...\nQuery: ").
|
||||
fn embed_prefix(key: &str) -> String {
|
||||
std::env::var(key)
|
||||
.map(|v| v.replace("\\n", "\n"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Embed a search query. Applies `EMBED_QUERY_PREFIX` (default empty) —
|
||||
/// retrieval models distinguish query-side from document-side text:
|
||||
/// nomic v1.5 wants `search_query: `, Qwen3-Embedding wants
|
||||
/// `Instruct: <task>\nQuery: `. Must pair with the document prefix the
|
||||
/// corpus was embedded with or similarity degrades.
|
||||
pub async fn embed_query(
|
||||
ollama: &OllamaClient,
|
||||
llamacpp: Option<&LlamaCppClient>,
|
||||
text: &str,
|
||||
) -> anyhow::Result<Vec<f32>> {
|
||||
let prefixed = format!("{}{}", embed_prefix("EMBED_QUERY_PREFIX"), text);
|
||||
embed_one(ollama, llamacpp, &prefixed).await
|
||||
}
|
||||
|
||||
/// Embed corpus text (the stored side of retrieval). Applies
|
||||
/// `EMBED_DOCUMENT_PREFIX` (default empty; nomic v1.5 wants
|
||||
/// `search_document: `, Qwen3-Embedding wants none).
|
||||
pub async fn embed_document(
|
||||
ollama: &OllamaClient,
|
||||
llamacpp: Option<&LlamaCppClient>,
|
||||
text: &str,
|
||||
) -> anyhow::Result<Vec<f32>> {
|
||||
let prefixed = format!("{}{}", embed_prefix("EMBED_DOCUMENT_PREFIX"), text);
|
||||
embed_one(ollama, llamacpp, &prefixed).await
|
||||
}
|
||||
|
||||
/// Embed a batch of strings via the configured local backend. Routes
|
||||
/// through llama-swap when `LLM_BACKEND=llamacpp` (and a client is
|
||||
/// configured), else Ollama. See [`local_backend_is_llamacpp`] for the
|
||||
/// rationale on consistency.
|
||||
pub async fn embed_many(
|
||||
ollama: &OllamaClient,
|
||||
llamacpp: Option<&LlamaCppClient>,
|
||||
texts: &[&str],
|
||||
) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
if local_backend_is_llamacpp() {
|
||||
if let Some(lc) = llamacpp {
|
||||
return <LlamaCppClient as LlmClient>::generate_embeddings(lc, texts).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"LLM_BACKEND=llamacpp but LlamaCppClient is unconfigured — \
|
||||
set LLAMA_SWAP_URL or switch to LLM_BACKEND=ollama"
|
||||
);
|
||||
}
|
||||
ollama.generate_embeddings(texts).await
|
||||
}
|
||||
|
||||
/// Embed one string via the configured local backend. Single-text
|
||||
/// convenience over [`embed_many`].
|
||||
pub async fn embed_one(
|
||||
ollama: &OllamaClient,
|
||||
llamacpp: Option<&LlamaCppClient>,
|
||||
text: &str,
|
||||
) -> anyhow::Result<Vec<f32>> {
|
||||
let mut vecs = embed_many(ollama, llamacpp, &[text]).await?;
|
||||
vecs.pop()
|
||||
.ok_or_else(|| anyhow::anyhow!("embedding backend returned no embeddings"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod env_dispatch_tests {
|
||||
use super::*;
|
||||
|
||||
/// Env vars are process-global, and the test harness runs in parallel —
|
||||
/// without this lock the `LLM_BACKEND` tests race each other and flake.
|
||||
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn with_env<F: FnOnce()>(key: &str, val: Option<&str>, f: F) {
|
||||
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let prev = std::env::var(key).ok();
|
||||
match val {
|
||||
Some(v) => unsafe { std::env::set_var(key, v) },
|
||||
None => unsafe { std::env::remove_var(key) },
|
||||
}
|
||||
f();
|
||||
match prev {
|
||||
Some(v) => unsafe { std::env::set_var(key, v) },
|
||||
None => unsafe { std::env::remove_var(key) },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_backend_defaults_to_ollama() {
|
||||
with_env("LLM_BACKEND", None, || {
|
||||
assert!(!local_backend_is_llamacpp());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_backend_llamacpp_case_insensitive() {
|
||||
with_env("LLM_BACKEND", Some("LlamaCpp"), || {
|
||||
assert!(local_backend_is_llamacpp());
|
||||
});
|
||||
with_env("LLM_BACKEND", Some(" llamacpp "), || {
|
||||
assert!(local_backend_is_llamacpp());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_backend_unknown_value_is_ollama() {
|
||||
with_env("LLM_BACKEND", Some("vllm"), || {
|
||||
assert!(!local_backend_is_llamacpp());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
//! Natural-language → structured-query translation for unified photo search.
|
||||
//!
|
||||
//! The unified search endpoint (`/photos/search/unified`, Phase 2) needs to
|
||||
//! turn a free-text query like *"sunset photos in Italy from last summer"*
|
||||
//! into the structured filter the existing `/photos` engine understands plus
|
||||
//! a semantic term for CLIP ranking. That translation is a single grounded
|
||||
//! LLM call, isolated here so it can be unit-tested without a network or the
|
||||
//! full `InsightGenerator`.
|
||||
//!
|
||||
//! Two-stage design:
|
||||
//! 1. The LLM emits a [`RawNlQuery`] — references are by *name* (tags) and
|
||||
//! dates as ISO strings, never numeric ids it could hallucinate.
|
||||
//! 2. [`resolve_raw_query`] maps names against the real tag vocabulary and
|
||||
//! converts ISO dates to unix seconds, producing a [`StructuredQuery`].
|
||||
//! A tag the model invents that isn't in the vocab is surfaced in
|
||||
//! `unmatched_tags` (the caller folds it back into the semantic term)
|
||||
//! rather than silently dropped — this is the anti-noise guard.
|
||||
//!
|
||||
//! Geocoding of `place` and person filtering are intentionally *not* handled
|
||||
//! here: `place` stays as text for the caller to forward-geocode (async, see
|
||||
//! `geo::forward_geocode`), and person filtering is deferred until a
|
||||
//! person→photos resolver exists.
|
||||
|
||||
use crate::ai::llm_client::{ChatMessage, LlmClient, Tool, strip_think_blocks};
|
||||
use anyhow::{Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Raw query object as emitted by the LLM. Tag references are by name
|
||||
/// (resolved against the real vocab in Rust); dates are ISO `YYYY-MM-DD`.
|
||||
/// Every field is optional so a partial / minimal model response still
|
||||
/// deserializes.
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
|
||||
pub struct RawNlQuery {
|
||||
/// Visual/scene description handed to CLIP for ranking. The descriptive
|
||||
/// remainder after structured filters are peeled off.
|
||||
#[serde(default)]
|
||||
pub semantic: Option<String>,
|
||||
/// Tag names the photos must have. Matched case-insensitively against
|
||||
/// the supplied vocabulary; non-matches land in `unmatched_tags`.
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Tag names the photos must NOT have.
|
||||
#[serde(default)]
|
||||
pub exclude_tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub camera_make: Option<String>,
|
||||
#[serde(default)]
|
||||
pub camera_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub lens_model: Option<String>,
|
||||
/// Free-text place/location name to forward-geocode (e.g. "Italy").
|
||||
#[serde(default)]
|
||||
pub place: Option<String>,
|
||||
/// Inclusive start date, ISO `YYYY-MM-DD`.
|
||||
#[serde(default)]
|
||||
pub date_from: Option<String>,
|
||||
/// Inclusive end date, ISO `YYYY-MM-DD`.
|
||||
#[serde(default)]
|
||||
pub date_to: Option<String>,
|
||||
/// "photo" | "video" — normalized in [`resolve_raw_query`].
|
||||
#[serde(default)]
|
||||
pub media_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolved structured query: tag names mapped to ids against the real
|
||||
/// vocab, ISO dates converted to unix seconds. `place` stays as text for the
|
||||
/// caller to forward-geocode into a gps circle. Serializable so the endpoint
|
||||
/// can echo it back to the client as "this is how I read your query"
|
||||
/// (editable filter chips).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct StructuredQuery {
|
||||
pub semantic: Option<String>,
|
||||
pub tag_ids: Vec<i32>,
|
||||
pub exclude_tag_ids: Vec<i32>,
|
||||
/// Tag names the model produced that don't exist in the vocabulary.
|
||||
/// The caller folds these back into the semantic term so the concept
|
||||
/// isn't lost — and surfacing them keeps a hallucinated tag from
|
||||
/// silently filtering the whole library to nothing.
|
||||
pub unmatched_tags: Vec<String>,
|
||||
pub camera_make: Option<String>,
|
||||
pub camera_model: Option<String>,
|
||||
pub lens_model: Option<String>,
|
||||
/// Raw place name awaiting forward-geocoding by the caller.
|
||||
pub place: Option<String>,
|
||||
pub date_from: Option<i64>,
|
||||
pub date_to: Option<i64>,
|
||||
/// Normalized to "photo" | "video"; `None` means no media-type filter.
|
||||
pub media_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Convert an ISO `YYYY-MM-DD` date to a unix timestamp (seconds). With
|
||||
/// `end_of_day`, returns 23:59:59 of that day so a `date_to` filter is
|
||||
/// inclusive of the whole day; otherwise 00:00:00. Returns `None` for any
|
||||
/// unparseable input (the filter is simply omitted rather than erroring).
|
||||
pub fn iso_to_unix(date: &str, end_of_day: bool) -> Option<i64> {
|
||||
let d = chrono::NaiveDate::parse_from_str(date.trim(), "%Y-%m-%d").ok()?;
|
||||
let time = if end_of_day {
|
||||
chrono::NaiveTime::from_hms_opt(23, 59, 59)?
|
||||
} else {
|
||||
chrono::NaiveTime::from_hms_opt(0, 0, 0)?
|
||||
};
|
||||
Some(d.and_time(time).and_utc().timestamp())
|
||||
}
|
||||
|
||||
/// Normalize a free-form media-type string to the engine's vocabulary.
|
||||
/// Anything that isn't clearly photo or video (including "all") yields
|
||||
/// `None` — no filter.
|
||||
fn normalize_media_type(raw: &str) -> Option<String> {
|
||||
match raw.trim().to_lowercase().as_str() {
|
||||
"photo" | "photos" | "image" | "images" | "picture" | "pictures" => {
|
||||
Some("photo".to_string())
|
||||
}
|
||||
"video" | "videos" | "movie" | "movies" | "clip" | "clips" => Some("video".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a raw LLM query against the real tag vocabulary, producing the
|
||||
/// structured filter. Pure — no network, no LLM — so it carries the
|
||||
/// correctness-critical mapping logic under unit test.
|
||||
///
|
||||
/// `tag_vocab` is `(tag_id, tag_name)` pairs (the shape `TagDao::get_all_tags`
|
||||
/// yields once the count is dropped). Matching is case-insensitive and exact
|
||||
/// on the trimmed name.
|
||||
pub fn resolve_raw_query(raw: RawNlQuery, tag_vocab: &[(i32, String)]) -> StructuredQuery {
|
||||
// Case-insensitive name → id lookup. Built once per call.
|
||||
let lookup: std::collections::HashMap<String, i32> = tag_vocab
|
||||
.iter()
|
||||
.map(|(id, name)| (name.trim().to_lowercase(), *id))
|
||||
.collect();
|
||||
|
||||
let resolve_names = |names: &[String], ids: &mut Vec<i32>, unmatched: &mut Vec<String>| {
|
||||
for name in names {
|
||||
let key = name.trim().to_lowercase();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match lookup.get(&key) {
|
||||
Some(id) if !ids.contains(id) => ids.push(*id),
|
||||
Some(_) => {} // duplicate, already collected
|
||||
None => {
|
||||
if !unmatched.iter().any(|u| u.eq_ignore_ascii_case(name)) {
|
||||
unmatched.push(name.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut tag_ids = Vec::new();
|
||||
let mut unmatched_tags = Vec::new();
|
||||
resolve_names(&raw.tags, &mut tag_ids, &mut unmatched_tags);
|
||||
|
||||
// Excluded tags that don't match a real tag are simply ignored — you
|
||||
// can't exclude a tag that doesn't exist, and folding them into
|
||||
// `semantic` would make no sense.
|
||||
let mut exclude_tag_ids = Vec::new();
|
||||
let mut exclude_unmatched = Vec::new();
|
||||
resolve_names(
|
||||
&raw.exclude_tags,
|
||||
&mut exclude_tag_ids,
|
||||
&mut exclude_unmatched,
|
||||
);
|
||||
|
||||
let clean = |s: Option<String>| s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
|
||||
|
||||
StructuredQuery {
|
||||
semantic: clean(raw.semantic),
|
||||
tag_ids,
|
||||
exclude_tag_ids,
|
||||
unmatched_tags,
|
||||
camera_make: clean(raw.camera_make),
|
||||
camera_model: clean(raw.camera_model),
|
||||
lens_model: clean(raw.lens_model),
|
||||
place: clean(raw.place),
|
||||
date_from: raw.date_from.as_deref().and_then(|d| iso_to_unix(d, false)),
|
||||
date_to: raw.date_to.as_deref().and_then(|d| iso_to_unix(d, true)),
|
||||
media_type: raw.media_type.as_deref().and_then(normalize_media_type),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the grounded system prompt. The model is told the current date (so
|
||||
/// "last summer" resolves) and the exact tag vocabulary (so it uses real
|
||||
/// tags or routes the concept to `semantic` instead of inventing one).
|
||||
fn build_system_prompt(tag_vocab: &[(i32, String)], today: chrono::NaiveDate) -> String {
|
||||
// Cap the vocab dump so a huge library doesn't blow the context window;
|
||||
// the most-used tags are the ones a query is likely to reference.
|
||||
const MAX_TAGS: usize = 400;
|
||||
let mut names: Vec<&str> = tag_vocab.iter().map(|(_, n)| n.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
let shown = names.len().min(MAX_TAGS);
|
||||
let vocab = names[..shown].join(", ");
|
||||
let truncation = if names.len() > MAX_TAGS {
|
||||
format!(" (showing {MAX_TAGS} of {} tags)", names.len())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!(
|
||||
"You translate a user's natural-language photo-search request into a JSON \
|
||||
filter. Today's date is {today}. Respond with ONLY a JSON object, no prose, no \
|
||||
code fences.\n\n\
|
||||
Schema (all fields optional):\n\
|
||||
{{\n \
|
||||
\"semantic\": string|null, // visual scene/subject for image similarity search\n \
|
||||
\"tags\": string[], // ONLY names from the tag list below\n \
|
||||
\"exclude_tags\": string[], // ONLY names from the tag list below\n \
|
||||
\"camera_make\": string|null,\n \
|
||||
\"camera_model\": string|null,\n \
|
||||
\"lens_model\": string|null,\n \
|
||||
\"place\": string|null, // a location name to look up (city, country, landmark)\n \
|
||||
\"date_from\": \"YYYY-MM-DD\"|null, // inclusive\n \
|
||||
\"date_to\": \"YYYY-MM-DD\"|null, // inclusive\n \
|
||||
\"media_type\": \"photo\"|\"video\"|null\n\
|
||||
}}\n\n\
|
||||
Rules:\n\
|
||||
- Put descriptive/visual concepts (\"sunset\", \"crowded beach\", \"red car\") in \"semantic\".\n\
|
||||
- Only use \"tags\"/\"exclude_tags\" values that appear EXACTLY in the tag list. If a \
|
||||
concept isn't a listed tag, put it in \"semantic\" instead — never invent a tag.\n\
|
||||
- Resolve relative dates against today's date (\"last summer\", \"2023\", \"last month\").\n\
|
||||
- Put place/location names in \"place\" (not \"semantic\").\n\
|
||||
- Omit (use null / empty array) anything the request doesn't mention.\n\n\
|
||||
Available tags{truncation}: {vocab}"
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract the JSON object from a model response that may include a leading
|
||||
/// `<think>` block, code fences, or trailing prose. Strips the think block
|
||||
/// first (so reasoning that mentions braces can't fool the scan), then
|
||||
/// returns the substring from the first `{` to the last `}` inclusive — or
|
||||
/// the trimmed text if no braces are found (which then fails to parse with a
|
||||
/// clear error).
|
||||
fn extract_json(raw: &str) -> String {
|
||||
let s = strip_think_blocks(raw);
|
||||
let start = s.find('{');
|
||||
let end = s.rfind('}');
|
||||
match (start, end) {
|
||||
(Some(a), Some(b)) if b >= a => s[a..=b].to_string(),
|
||||
_ => s.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a model response string into a [`StructuredQuery`], resolving names
|
||||
/// against the vocab. Separated from the LLM call so it's unit-testable.
|
||||
pub fn parse_response(response: &str, tag_vocab: &[(i32, String)]) -> Result<StructuredQuery> {
|
||||
let json = extract_json(response);
|
||||
let raw: RawNlQuery = serde_json::from_str(&json)
|
||||
.map_err(|e| anyhow!("failed to parse NL query JSON: {e}; raw response: {response:?}"))?;
|
||||
Ok(resolve_raw_query(raw, tag_vocab))
|
||||
}
|
||||
|
||||
/// Translate a natural-language query into a [`StructuredQuery`] via one
|
||||
/// grounded LLM call. The `client` is any configured backend (the unified
|
||||
/// endpoint passes the resolved chat backend); `tag_vocab` grounds the tag
|
||||
/// mapping; `today` anchors relative-date resolution.
|
||||
pub async fn translate_nl_query(
|
||||
client: &dyn LlmClient,
|
||||
nl: &str,
|
||||
tag_vocab: &[(i32, String)],
|
||||
today: chrono::NaiveDate,
|
||||
) -> Result<StructuredQuery> {
|
||||
let system = build_system_prompt(tag_vocab, today);
|
||||
let messages = vec![ChatMessage::system(system), ChatMessage::user(nl)];
|
||||
let (msg, _, _) = client.chat_with_tools(messages, Vec::<Tool>::new()).await?;
|
||||
parse_response(&msg.content, tag_vocab)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn vocab() -> Vec<(i32, String)> {
|
||||
vec![
|
||||
(1, "beach".to_string()),
|
||||
(2, "Sunset".to_string()), // mixed case to exercise case-insensitivity
|
||||
(3, "family".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_to_unix_start_and_end_of_day() {
|
||||
// 2023-01-01 UTC midnight = 1672531200.
|
||||
assert_eq!(iso_to_unix("2023-01-01", false), Some(1_672_531_200));
|
||||
// End of that day is 86399 seconds later.
|
||||
assert_eq!(
|
||||
iso_to_unix("2023-01-01", true),
|
||||
Some(1_672_531_200 + 86_399)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_to_unix_rejects_garbage() {
|
||||
assert_eq!(iso_to_unix("last summer", false), None);
|
||||
assert_eq!(iso_to_unix("2023-13-99", false), None);
|
||||
assert_eq!(iso_to_unix("", false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_matches_tags_case_insensitively() {
|
||||
let raw = RawNlQuery {
|
||||
tags: vec!["BEACH".to_string(), "sunset".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let q = resolve_raw_query(raw, &vocab());
|
||||
assert_eq!(q.tag_ids, vec![1, 2]);
|
||||
assert!(q.unmatched_tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_surfaces_unmatched_tags_not_silently_dropped() {
|
||||
// A hallucinated / non-vocab tag must be surfaced so the caller can
|
||||
// fold it into semantic — never silently used as a hard filter.
|
||||
let raw = RawNlQuery {
|
||||
tags: vec!["beach".to_string(), "golden hour".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let q = resolve_raw_query(raw, &vocab());
|
||||
assert_eq!(q.tag_ids, vec![1]);
|
||||
assert_eq!(q.unmatched_tags, vec!["golden hour".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_dedups_repeated_tags() {
|
||||
let raw = RawNlQuery {
|
||||
tags: vec![
|
||||
"beach".to_string(),
|
||||
"Beach".to_string(),
|
||||
"beach".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let q = resolve_raw_query(raw, &vocab());
|
||||
assert_eq!(q.tag_ids, vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_normalizes_media_type_and_dates() {
|
||||
let raw = RawNlQuery {
|
||||
media_type: Some("Videos".to_string()),
|
||||
date_from: Some("2023-06-01".to_string()),
|
||||
date_to: Some("2023-06-30".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let q = resolve_raw_query(raw, &vocab());
|
||||
assert_eq!(q.media_type.as_deref(), Some("video"));
|
||||
assert_eq!(q.date_from, iso_to_unix("2023-06-01", false));
|
||||
assert_eq!(q.date_to, iso_to_unix("2023-06-30", true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_media_type_all_is_no_filter() {
|
||||
let raw = RawNlQuery {
|
||||
media_type: Some("all".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(resolve_raw_query(raw, &vocab()).media_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_trims_and_empties_to_none() {
|
||||
let raw = RawNlQuery {
|
||||
semantic: Some(" ".to_string()),
|
||||
camera_make: Some(" Fujifilm ".to_string()),
|
||||
place: Some("".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let q = resolve_raw_query(raw, &vocab());
|
||||
assert_eq!(q.semantic, None);
|
||||
assert_eq!(q.camera_make.as_deref(), Some("Fujifilm"));
|
||||
assert_eq!(q.place, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_handles_code_fences_and_prose() {
|
||||
let resp = "Here is the filter:\n```json\n{\"semantic\":\"sunset\",\"tags\":[\"beach\"]}\n```\nDone.";
|
||||
let q = parse_response(resp, &vocab()).expect("parse");
|
||||
assert_eq!(q.semantic.as_deref(), Some("sunset"));
|
||||
assert_eq!(q.tag_ids, vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_handles_think_block_then_json() {
|
||||
let resp = "<think>user wants beach sunsets</think>{\"tags\":[\"beach\",\"sunset\"]}";
|
||||
let q = parse_response(resp, &vocab()).expect("parse");
|
||||
assert_eq!(q.tag_ids, vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_response_errors_on_non_json() {
|
||||
assert!(parse_response("I cannot help with that.", &vocab()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_system_prompt_includes_date_and_vocab() {
|
||||
let today = chrono::NaiveDate::from_ymd_opt(2026, 6, 14).unwrap();
|
||||
let prompt = build_system_prompt(&vocab(), today);
|
||||
assert!(
|
||||
prompt.contains("2026-06-14"),
|
||||
"prompt should state today's date"
|
||||
);
|
||||
assert!(prompt.contains("beach"), "prompt should list the vocab");
|
||||
assert!(
|
||||
prompt.contains("never invent a tag"),
|
||||
"prompt should warn against inventing tags"
|
||||
);
|
||||
}
|
||||
}
|
||||
+23
-68
@@ -360,7 +360,18 @@ impl OllamaClient {
|
||||
/// Extract final answer from thinking model output
|
||||
/// Handles <think>...</think> tags and takes everything after
|
||||
fn extract_final_answer(&self, response: &str) -> String {
|
||||
crate::ai::llm_client::strip_think_blocks(response)
|
||||
let response = response.trim();
|
||||
|
||||
// Look for </think> tag and take everything after it
|
||||
if let Some(pos) = response.find("</think>") {
|
||||
let answer = response[pos + 8..].trim();
|
||||
if !answer.is_empty() {
|
||||
return answer.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return the whole response trimmed
|
||||
response.to_string()
|
||||
}
|
||||
|
||||
async fn try_generate(
|
||||
@@ -413,7 +424,10 @@ impl OllamaClient {
|
||||
self.generate_with_images(prompt, system, None).await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// Variant of `generate` that sets Ollama's top-level `think: false`.
|
||||
/// Used by latency-sensitive callers like the rerank pass, where the
|
||||
/// task has nothing to reason about and chain-of-thought tokens are
|
||||
/// wasted wall time. Server-side no-op on non-reasoning models.
|
||||
pub async fn generate_no_think(&self, prompt: &str, system: Option<&str>) -> Result<String> {
|
||||
self.generate_with_options(prompt, system, None, Some(false))
|
||||
.await
|
||||
@@ -548,16 +562,7 @@ Capture the key moment or theme. Return ONLY the title, nothing else."#,
|
||||
let title = self
|
||||
.generate_with_images(&prompt, Some(system), None)
|
||||
.await?;
|
||||
// Models decorate despite "Return ONLY the title": quotes, bold
|
||||
// markers, sometimes a "Title:" label.
|
||||
use crate::ai::insight_generator::strip_title_markdown;
|
||||
let cleaned = strip_title_markdown(title.trim());
|
||||
let cleaned = cleaned
|
||||
.strip_prefix("Title:")
|
||||
.or_else(|| cleaned.strip_prefix("title:"))
|
||||
.map(strip_title_markdown)
|
||||
.unwrap_or(cleaned);
|
||||
Ok(cleaned.to_string())
|
||||
Ok(title.trim().trim_matches('"').to_string())
|
||||
}
|
||||
|
||||
/// Generate a summary for a single photo based on its context
|
||||
@@ -844,14 +849,11 @@ Analyze the image and use specific details from both the visual content and the
|
||||
if !chunk.message.role.is_empty() {
|
||||
role = chunk.message.role;
|
||||
}
|
||||
// Ollama ≥0.8 can stream tool_calls incrementally
|
||||
// across chunks (older servers attach them all to
|
||||
// one chunk) — append rather than overwrite so
|
||||
// calls from earlier chunks survive.
|
||||
// Ollama only attaches tool_calls on the final chunk.
|
||||
if let Some(tcs) = chunk.message.tool_calls
|
||||
&& !tcs.is_empty()
|
||||
{
|
||||
append_streamed_tool_calls(&mut tool_calls, tcs);
|
||||
tool_calls = Some(tcs);
|
||||
}
|
||||
if chunk.done {
|
||||
prompt_eval_count = chunk.prompt_eval_count;
|
||||
@@ -1055,14 +1057,13 @@ Analyze the image and use specific details from both the visual content and the
|
||||
}
|
||||
};
|
||||
|
||||
// Validate embedding dimensions (EMBEDDING_DIM; 768 for nomic-embed-text:v1.5)
|
||||
// Validate embedding dimensions (should be 768 for nomic-embed-text:v1.5)
|
||||
for (i, embedding) in embeddings.iter().enumerate() {
|
||||
if embedding.len() != crate::ai::embedding_dim() {
|
||||
if embedding.len() != 768 {
|
||||
log::warn!(
|
||||
"Unexpected embedding dimensions for item {}: {} (expected {})",
|
||||
"Unexpected embedding dimensions for item {}: {} (expected 768)",
|
||||
i,
|
||||
embedding.len(),
|
||||
crate::ai::embedding_dim()
|
||||
embedding.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1331,20 +1332,8 @@ struct OllamaEmbedResponse {
|
||||
embeddings: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
/// Accumulate tool calls streamed across NDJSON chunks. Ollama ≥0.8 may
|
||||
/// emit each tool call on its own chunk; replacing the accumulator on every
|
||||
/// chunk would keep only the last call, so extend instead.
|
||||
fn append_streamed_tool_calls(
|
||||
acc: &mut Option<Vec<crate::ai::llm_client::ToolCall>>,
|
||||
new: Vec<crate::ai::llm_client::ToolCall>,
|
||||
) {
|
||||
acc.get_or_insert_with(Vec::new).extend(new);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::append_streamed_tool_calls;
|
||||
use crate::ai::llm_client::{ToolCall, ToolCallFunction};
|
||||
|
||||
#[test]
|
||||
fn generate_photo_description_prompt_is_concise() {
|
||||
@@ -1355,38 +1344,4 @@ mod tests {
|
||||
Focus on the people, location, and activity.";
|
||||
assert!(prompt.len() < 200, "Prompt should be concise");
|
||||
}
|
||||
|
||||
fn call(name: &str) -> ToolCall {
|
||||
ToolCall {
|
||||
id: None,
|
||||
function: ToolCallFunction {
|
||||
name: name.to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_tool_calls_across_chunks_accumulate() {
|
||||
// Two tool calls arriving in two separate stream chunks must BOTH
|
||||
// survive assembly — the old `tool_calls = Some(tcs)` kept only the
|
||||
// last chunk's calls.
|
||||
let mut acc: Option<Vec<ToolCall>> = None;
|
||||
append_streamed_tool_calls(&mut acc, vec![call("get_sms_messages")]);
|
||||
append_streamed_tool_calls(&mut acc, vec![call("reverse_geocode")]);
|
||||
|
||||
let calls = acc.expect("tool calls accumulated");
|
||||
assert_eq!(calls.len(), 2);
|
||||
assert_eq!(calls[0].function.name, "get_sms_messages");
|
||||
assert_eq!(calls[1].function.name, "reverse_geocode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_tool_calls_single_chunk_batch_kept_intact() {
|
||||
// Older Ollama servers attach all calls to one chunk — unchanged.
|
||||
let mut acc: Option<Vec<ToolCall>> = None;
|
||||
append_streamed_tool_calls(&mut acc, vec![call("a"), call("b")]);
|
||||
let calls = acc.expect("tool calls accumulated");
|
||||
assert_eq!(calls.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
// User-configurable pronunciation overrides for TTS. Chatterbox mispronounces
|
||||
// place names ("Worcester"), initialisms ("WSL"), and clipped abbreviations
|
||||
// ("blvd"), so we rewrite them to phonetic spellings before synthesis.
|
||||
//
|
||||
// The map lives in a JSON file on the server — a flat object of
|
||||
// `"written form": "spoken form"` pairs, e.g.:
|
||||
//
|
||||
// {
|
||||
// "Worcester": "Wuster",
|
||||
// "WSL": "W S L",
|
||||
// "blvd": "boulevard",
|
||||
// "Dr.": "Doctor"
|
||||
// }
|
||||
//
|
||||
// Path comes from `TTS_PRONUNCIATIONS_PATH` (default `tts_pronunciations.json`
|
||||
// in the working directory). A missing file simply disables the feature. The
|
||||
// file is re-read whenever its mtime changes, so edits apply to the next
|
||||
// synthesis without a restart; a malformed edit keeps the last good map and
|
||||
// logs the parse error instead of silently dropping all overrides.
|
||||
//
|
||||
// Matching rules:
|
||||
// - Whole words only — `cat` never rewrites `category`. (Boundaries are only
|
||||
// asserted next to word characters, so keys like `Dr.` still work.)
|
||||
// - Smartcase: an all-lowercase key matches case-insensitively; a key with
|
||||
// any uppercase matches exactly. That lets `worcester` catch every casing
|
||||
// while `US` (the country) leaves the pronoun `us` alone.
|
||||
// - Longer keys win over shorter ones (`New York Times` before `New York`).
|
||||
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// A compiled pronunciation map: one alternation regex over every key plus
|
||||
/// the lookup tables the replacement closure resolves matches against.
|
||||
#[derive(Default)]
|
||||
struct CompiledMap {
|
||||
/// `None` when the map is empty — apply() is then a no-op.
|
||||
regex: Option<Regex>,
|
||||
/// Case-sensitive entries, keyed verbatim.
|
||||
exact: HashMap<String, String>,
|
||||
/// Case-insensitive entries, keyed lowercased.
|
||||
folded: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl CompiledMap {
|
||||
fn from_entries(entries: &HashMap<String, String>) -> Self {
|
||||
let mut keys: Vec<&str> = entries
|
||||
.keys()
|
||||
.map(|k| k.as_str())
|
||||
.filter(|k| !k.trim().is_empty())
|
||||
.collect();
|
||||
if keys.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
// Longest key first so overlapping entries prefer the more specific
|
||||
// one (regex alternation is first-match-wins, not longest-match).
|
||||
keys.sort_by(|a, b| b.len().cmp(&a.len()).then(a.cmp(b)));
|
||||
|
||||
let mut exact = HashMap::new();
|
||||
let mut folded = HashMap::new();
|
||||
let alternatives: Vec<String> = keys
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let escaped = regex::escape(key);
|
||||
// Only assert a word boundary where the key edge is a word
|
||||
// character — `\b` adjacent to punctuation (e.g. the dot in
|
||||
// `Dr.`) would otherwise never match.
|
||||
let lead = if key
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_alphanumeric() || c == '_')
|
||||
{
|
||||
r"\b"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let trail = if key
|
||||
.chars()
|
||||
.last()
|
||||
.is_some_and(|c| c.is_alphanumeric() || c == '_')
|
||||
{
|
||||
r"\b"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let case_sensitive = key.chars().any(|c| c.is_uppercase());
|
||||
if case_sensitive {
|
||||
exact.insert(key.to_string(), entries[*key].clone());
|
||||
format!("{lead}{escaped}{trail}")
|
||||
} else {
|
||||
folded.insert(key.to_lowercase(), entries[*key].clone());
|
||||
format!("{lead}(?i:{escaped}){trail}")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Escaped fixed strings can't produce an invalid pattern; if one ever
|
||||
// does, treat the whole map as empty rather than panicking a handler.
|
||||
let pattern = alternatives.join("|");
|
||||
let regex = match Regex::new(&pattern) {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
log::error!("pronunciation map failed to compile: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
Self {
|
||||
regex,
|
||||
exact,
|
||||
folded,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&self, text: &str) -> String {
|
||||
let Some(re) = &self.regex else {
|
||||
return text.to_string();
|
||||
};
|
||||
re.replace_all(text, |caps: ®ex::Captures| {
|
||||
let m = &caps[0];
|
||||
self.exact
|
||||
.get(m)
|
||||
.or_else(|| self.folded.get(&m.to_lowercase()))
|
||||
.cloned()
|
||||
// Unreachable in practice — every alternative came from one
|
||||
// of the two maps — but never drop the user's text.
|
||||
.unwrap_or_else(|| m.to_string())
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
struct CacheEntry {
|
||||
mtime: Option<SystemTime>,
|
||||
compiled: Arc<CompiledMap>,
|
||||
}
|
||||
|
||||
static CACHE: LazyLock<StdMutex<Option<CacheEntry>>> = LazyLock::new(|| StdMutex::new(None));
|
||||
|
||||
fn config_path() -> String {
|
||||
std::env::var("TTS_PRONUNCIATIONS_PATH")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "tts_pronunciations.json".to_string())
|
||||
}
|
||||
|
||||
/// Load the compiled map, re-reading the file only when its mtime changed
|
||||
/// since the last call (or it appeared/disappeared). Synthesis is serialized
|
||||
/// on a single GPU permit, so a stat per call is noise.
|
||||
fn current_map() -> Arc<CompiledMap> {
|
||||
let path_s = config_path();
|
||||
let path = Path::new(&path_s);
|
||||
let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
|
||||
|
||||
let mut cache = CACHE.lock().unwrap();
|
||||
if let Some(entry) = cache.as_ref()
|
||||
&& entry.mtime == mtime
|
||||
{
|
||||
return entry.compiled.clone();
|
||||
}
|
||||
|
||||
let compiled = match mtime {
|
||||
None => Arc::new(CompiledMap::default()), // no file → no overrides
|
||||
Some(_) => match std::fs::read_to_string(path)
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|s| Ok(serde_json::from_str::<HashMap<String, String>>(&s)?))
|
||||
{
|
||||
Ok(entries) => {
|
||||
log::info!(
|
||||
"loaded {} pronunciation override(s) from {path_s}",
|
||||
entries.len()
|
||||
);
|
||||
Arc::new(CompiledMap::from_entries(&entries))
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("failed to load pronunciation map {path_s}: {e}");
|
||||
// Keep serving the previous map rather than regressing to
|
||||
// none mid-edit; still record the new mtime so the error
|
||||
// logs once per bad save, not once per synthesis.
|
||||
cache
|
||||
.as_ref()
|
||||
.map(|c| c.compiled.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
},
|
||||
};
|
||||
*cache = Some(CacheEntry {
|
||||
mtime,
|
||||
compiled: compiled.clone(),
|
||||
});
|
||||
compiled
|
||||
}
|
||||
|
||||
/// Rewrite configured words/abbreviations to their phonetic spellings.
|
||||
/// Call on cleaned (post-markdown-strip) text, right before synthesis.
|
||||
pub fn apply_pronunciations(text: &str) -> String {
|
||||
current_map().apply(text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn compile(pairs: &[(&str, &str)]) -> CompiledMap {
|
||||
let entries = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
CompiledMap::from_entries(&entries)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_map_is_a_noop() {
|
||||
let m = compile(&[]);
|
||||
assert_eq!(m.apply("nothing changes"), "nothing changes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_whole_words_only() {
|
||||
let m = compile(&[("cat", "kitty")]);
|
||||
assert_eq!(m.apply("the cat sat"), "the kitty sat");
|
||||
// No substring rewrites.
|
||||
assert_eq!(m.apply("the category"), "the category");
|
||||
assert_eq!(m.apply("concatenate"), "concatenate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowercase_keys_match_any_casing() {
|
||||
let m = compile(&[("worcester", "Wuster")]);
|
||||
assert_eq!(m.apply("Worcester is nice"), "Wuster is nice");
|
||||
assert_eq!(m.apply("in WORCESTER today"), "in Wuster today");
|
||||
assert_eq!(m.apply("worcester sauce"), "Wuster sauce");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_keys_match_case_sensitively() {
|
||||
let m = compile(&[("US", "U S")]);
|
||||
assert_eq!(m.apply("the US economy"), "the U S economy");
|
||||
// The pronoun survives.
|
||||
assert_eq!(m.apply("join us today"), "join us today");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_with_punctuation_work() {
|
||||
// `\b` is only asserted next to word characters, so the trailing dot
|
||||
// doesn't break matching.
|
||||
let m = compile(&[("Dr.", "Doctor"), ("blvd", "boulevard")]);
|
||||
assert_eq!(
|
||||
m.apply("Dr. Smith on Sunset blvd"),
|
||||
"Doctor Smith on Sunset boulevard"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_keys_win_over_shorter() {
|
||||
let m = compile(&[("new york", "Noo York"), ("new york times", "the Times")]);
|
||||
assert_eq!(m.apply("read the new york times"), "read the the Times");
|
||||
assert_eq!(m.apply("visit new york soon"), "visit Noo York soon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_occurrences_all_rewrite() {
|
||||
let m = compile(&[("wsl", "W S L")]);
|
||||
assert_eq!(m.apply("WSL and wsl and Wsl"), "W S L and W S L and W S L");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_text_is_verbatim() {
|
||||
// Replacements aren't re-scanned — a value containing another key
|
||||
// doesn't cascade.
|
||||
let m = compile(&[("a1", "b2"), ("b2", "c3")]);
|
||||
assert_eq!(m.apply("a1"), "b2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_keys_are_ignored() {
|
||||
let m = compile(&[("", "x"), (" ", "y"), ("ok", "fine")]);
|
||||
assert_eq!(m.apply("ok then"), "fine then");
|
||||
}
|
||||
}
|
||||
@@ -281,9 +281,6 @@ impl SmsApiClient {
|
||||
if let Some(cid) = params.contact_id {
|
||||
url.push_str(&format!("&contact_id={}", cid));
|
||||
}
|
||||
if let Some(ref c) = params.contact {
|
||||
url.push_str(&format!("&contact={}", urlencoding::encode(c)));
|
||||
}
|
||||
if let Some(off) = params.offset {
|
||||
url.push_str(&format!("&offset={}", off));
|
||||
}
|
||||
@@ -416,9 +413,6 @@ pub struct SmsSearchParams<'a> {
|
||||
pub mode: &'a str,
|
||||
pub limit: usize,
|
||||
pub contact_id: Option<i64>,
|
||||
/// Contact name (case-insensitive). Resolved to a numeric ID by the
|
||||
/// SMS-API server when `contact_id` is not set.
|
||||
pub contact: Option<String>,
|
||||
/// Unix-seconds inclusive lower bound on `date`.
|
||||
pub date_from: Option<i64>,
|
||||
/// Unix-seconds inclusive upper bound on `date`.
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Thin async HTTP client for Apollo's `/api/internal/tags/*` endpoints.
|
||||
//!
|
||||
//! Apollo hosts the RAM++ auto-tag inference service alongside insightface.
|
||||
//! This client is the ImageApi side — shove image bytes through `/auto` and
|
||||
//! get back a list of `(name, confidence)` predictions over RAM++'s
|
||||
//! ~4585-tag vocabulary.
|
||||
//!
|
||||
//! Mirrors `face_client.rs` shape: optional base URL (None = disabled), one
|
||||
//! reqwest client with a generous timeout because GPU inference under a
|
||||
//! backlog can queue server-side (Apollo's threadpool is bounded to 1
|
||||
//! worker on CUDA).
|
||||
//!
|
||||
//! Configured via `APOLLO_TAG_API_BASE_URL`, falling back to
|
||||
//! `APOLLO_API_BASE_URL` when the dedicated var is unset (single-Apollo
|
||||
//! deploys are the common case). Both unset → `is_enabled()` returns false
|
||||
//! and the probe binary / future backlog drain no-op.
|
||||
//!
|
||||
//! Wire format: multipart/form-data with `file=<bytes>` and `meta=<json>`.
|
||||
//! `meta` carries `{content_hash, library_id, rel_path, threshold?}` —
|
||||
//! Apollo logs the path/lib for traceability and reads `threshold` to
|
||||
//! override the engine default for that call (the probe binary uses this
|
||||
//! to sweep without restarting Apollo).
|
||||
//!
|
||||
//! Error mapping (reflected in [`TagDetectError`]):
|
||||
//! - 422 `decode_failed` → permanent: ImageApi marks `status='failed'` and
|
||||
//! doesn't retry until a manual rerun.
|
||||
//! - 200 with `tags:[]` → `status='no_tags'` marker (success-with-zero).
|
||||
//! - 503 `cuda_oom` / `engine_unavailable` → defer-and-retry: no marker
|
||||
//! written.
|
||||
//! - Any other 5xx / network error → defer.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TagMeta {
|
||||
pub content_hash: String,
|
||||
pub library_id: i32,
|
||||
pub rel_path: String,
|
||||
/// Per-call threshold override. Apollo's engine default (0.68 for
|
||||
/// ram_plus_swin_large_14m) is used when unset. The probe binary
|
||||
/// uses this to sweep without restarting Apollo.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub threshold: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TagPrediction {
|
||||
pub name: String,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TagResponse {
|
||||
pub model_version: String,
|
||||
pub duration_ms: i64,
|
||||
pub threshold: f32,
|
||||
pub tags: Vec<TagPrediction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)] // Reported by Apollo; load_error consumed by future health probe
|
||||
pub struct TagHealth {
|
||||
pub loaded: bool,
|
||||
pub device: String,
|
||||
pub model_version: String,
|
||||
pub image_size: i32,
|
||||
pub threshold: f32,
|
||||
#[serde(default)]
|
||||
pub load_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Distinguishes permanent failures (don't retry) from transient ones
|
||||
/// (defer and retry on next scan tick). Mirrors `FaceDetectError` so the
|
||||
/// future backlog drain can use the same marker-row decision tree.
|
||||
#[derive(Debug)]
|
||||
pub enum TagDetectError {
|
||||
/// Apollo refused the bytes for a reason that won't change on retry
|
||||
/// (decode failure, zero-dim image). Mark `status='failed'`.
|
||||
Permanent(anyhow::Error),
|
||||
/// Apollo couldn't process this turn but might next time (CUDA OOM,
|
||||
/// engine not loaded yet, network hiccup). Don't mark anything.
|
||||
Transient(anyhow::Error),
|
||||
/// Feature is disabled (no APOLLO_TAG_API_BASE_URL / APOLLO_API_BASE_URL).
|
||||
/// Caller should silently no-op.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TagDetectError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TagDetectError::Permanent(e) => write!(f, "permanent: {e}"),
|
||||
TagDetectError::Transient(e) => write!(f, "transient: {e}"),
|
||||
TagDetectError::Disabled => write!(f, "tag client disabled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TagDetectError {}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TagClient {
|
||||
client: Client,
|
||||
/// `None` → disabled. Trailing slash trimmed at construction so url
|
||||
/// building doesn't double up.
|
||||
base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl TagClient {
|
||||
pub fn new(base_url: Option<String>) -> Self {
|
||||
// 60 s timeout: GPU inference is fast (~50–150 ms on RTX-class
|
||||
// hardware) but Apollo's 1-worker threadpool means a backlog drain
|
||||
// queues server-side. 60 s is enough headroom for a small queue
|
||||
// depth without surfacing a false transient.
|
||||
let timeout_secs = std::env::var("TAG_DETECT_TIMEOUT_SEC")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(60);
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.expect("reqwest client build");
|
||||
Self {
|
||||
client,
|
||||
base_url: base_url.map(|u| u.trim_end_matches('/').to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a client from the standard env vars. APOLLO_TAG_API_BASE_URL
|
||||
/// wins; falls back to APOLLO_API_BASE_URL. Both unset → disabled.
|
||||
pub fn from_env() -> Self {
|
||||
let base = std::env::var("APOLLO_TAG_API_BASE_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("APOLLO_API_BASE_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
});
|
||||
Self::new(base)
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.base_url.is_some()
|
||||
}
|
||||
|
||||
/// Run RAM++ auto-tagging over `bytes`. Empty `tags[]` is the no-tags
|
||||
/// signal — caller writes a marker row in the persistence phase.
|
||||
pub async fn auto_tag(
|
||||
&self,
|
||||
bytes: Vec<u8>,
|
||||
meta: TagMeta,
|
||||
) -> std::result::Result<TagResponse, TagDetectError> {
|
||||
let Some(base) = self.base_url.as_deref() else {
|
||||
return Err(TagDetectError::Disabled);
|
||||
};
|
||||
let url = format!("{}/api/internal/tags/auto", base);
|
||||
self.post_multipart(&url, bytes, &meta).await
|
||||
}
|
||||
|
||||
/// Engine reachability + device/model report.
|
||||
#[allow(dead_code)] // consumed by future startup probe
|
||||
pub async fn health(&self) -> Result<TagHealth> {
|
||||
let base = self.base_url.as_deref().context("tag client disabled")?;
|
||||
let url = format!("{}/api/internal/tags/health", base);
|
||||
let resp = self.client.get(&url).send().await?.error_for_status()?;
|
||||
let body: TagHealth = resp.json().await?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn post_multipart(
|
||||
&self,
|
||||
url: &str,
|
||||
bytes: Vec<u8>,
|
||||
meta: &TagMeta,
|
||||
) -> std::result::Result<TagResponse, TagDetectError> {
|
||||
let meta_json = serde_json::to_string(meta)
|
||||
.map_err(|e| TagDetectError::Permanent(anyhow::anyhow!("meta serialize: {e}")))?;
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("meta", meta_json)
|
||||
.part(
|
||||
"file",
|
||||
reqwest::multipart::Part::bytes(bytes)
|
||||
.file_name(meta.rel_path.clone())
|
||||
.mime_str("application/octet-stream")
|
||||
.unwrap_or_else(|_| reqwest::multipart::Part::bytes(Vec::new())),
|
||||
);
|
||||
|
||||
let resp = match self.client.post(url).multipart(form).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.is_timeout() || e.is_connect() => {
|
||||
return Err(TagDetectError::Transient(anyhow::anyhow!(
|
||||
"tag client network: {e}"
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(TagDetectError::Transient(anyhow::anyhow!(
|
||||
"tag client request: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
let body: TagResponse = resp.json().await.map_err(|e| {
|
||||
TagDetectError::Transient(anyhow::anyhow!("tag response decode: {e}"))
|
||||
})?;
|
||||
return Ok(body);
|
||||
}
|
||||
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
Err(classify_error_response(status.as_u16(), &body_text))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulled out as a pure function so the marker-row contract is unit-testable
|
||||
/// without spinning up an HTTP server. Behavior matches face_client::classify
|
||||
/// so the future backlog drain can share the same retry policy.
|
||||
fn classify_error_response(status: u16, body_text: &str) -> TagDetectError {
|
||||
let detail_code = serde_json::from_str::<serde_json::Value>(body_text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("detail")
|
||||
.and_then(|d| d.as_str().map(str::to_string))
|
||||
.or_else(|| {
|
||||
v.get("detail")
|
||||
.and_then(|d| d.get("code"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if status == 422 {
|
||||
return TagDetectError::Permanent(anyhow::anyhow!(
|
||||
"tag detect 422 {}: {}",
|
||||
detail_code,
|
||||
body_text
|
||||
));
|
||||
}
|
||||
if status == 503 {
|
||||
return TagDetectError::Transient(anyhow::anyhow!(
|
||||
"tag detect 503 {}: {}",
|
||||
detail_code,
|
||||
body_text
|
||||
));
|
||||
}
|
||||
// 408 / 413 / 429 are operator-fixable infra issues — defer so the
|
||||
// next pass retries naturally once the proxy is fixed (see
|
||||
// face_client::classify_error_response for the cautionary tale).
|
||||
if matches!(status, 408 | 413 | 429) {
|
||||
return TagDetectError::Transient(anyhow::anyhow!(
|
||||
"tag detect {} {}: {}",
|
||||
status,
|
||||
detail_code,
|
||||
body_text
|
||||
));
|
||||
}
|
||||
if (400..500).contains(&status) {
|
||||
TagDetectError::Permanent(anyhow::anyhow!(
|
||||
"tag detect {} {}: {}",
|
||||
status,
|
||||
detail_code,
|
||||
body_text
|
||||
))
|
||||
} else {
|
||||
TagDetectError::Transient(anyhow::anyhow!(
|
||||
"tag detect {} {}: {}",
|
||||
status,
|
||||
detail_code,
|
||||
body_text
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn is_permanent(e: &TagDetectError) -> bool {
|
||||
matches!(e, TagDetectError::Permanent(_))
|
||||
}
|
||||
fn is_transient(e: &TagDetectError) -> bool {
|
||||
matches!(e, TagDetectError::Transient(_))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_422_decode_failed_is_permanent() {
|
||||
let e = classify_error_response(422, r#"{"detail":"decode_failed: bad bytes"}"#);
|
||||
assert!(is_permanent(&e));
|
||||
assert!(format!("{e}").contains("decode_failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_503_cuda_oom_is_transient() {
|
||||
let e = classify_error_response(
|
||||
503,
|
||||
r#"{"detail":{"code":"cuda_oom","error":"out of memory"}}"#,
|
||||
);
|
||||
assert!(is_transient(&e));
|
||||
assert!(format!("{e}").contains("cuda_oom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_5xx_is_transient_other_4xx_is_permanent() {
|
||||
assert!(is_transient(&classify_error_response(500, "")));
|
||||
assert!(is_permanent(&classify_error_response(400, "{}")));
|
||||
assert!(is_permanent(&classify_error_response(404, "{}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_infra_4xx_is_transient() {
|
||||
assert!(is_transient(&classify_error_response(408, "")));
|
||||
assert!(is_transient(&classify_error_response(413, "<html>")));
|
||||
assert!(is_transient(&classify_error_response(429, "{}")));
|
||||
}
|
||||
}
|
||||
-1313
File diff suppressed because it is too large
Load Diff
@@ -1,748 +0,0 @@
|
||||
use crate::ai::insight_chat::ChatStreamEvent;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
use tokio::task::AbortHandle;
|
||||
|
||||
/// Maximum number of events buffered per turn. Agentic turns typically
|
||||
/// produce ~120 events; 500 provides 4× headroom. When exceeded, oldest
|
||||
/// events are evicted from the front.
|
||||
const MAX_BUFFERED_EVENTS: usize = 500;
|
||||
|
||||
/// Turn status codes used by `TurnEntry::status`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum TurnStatus {
|
||||
Running = 0,
|
||||
Done = 1,
|
||||
Error = 2,
|
||||
Cancelled = 3,
|
||||
}
|
||||
|
||||
impl From<u32> for TurnStatus {
|
||||
fn from(v: u32) -> Self {
|
||||
match v {
|
||||
0 => TurnStatus::Running,
|
||||
1 => TurnStatus::Done,
|
||||
2 => TurnStatus::Error,
|
||||
3 => TurnStatus::Cancelled,
|
||||
_ => TurnStatus::Running,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TurnStatus::Running => "running",
|
||||
TurnStatus::Done => "done",
|
||||
TurnStatus::Error => "error",
|
||||
TurnStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared metadata about a turn, read by the SSE replay handler to emit
|
||||
/// the initial `turn_info` event and to decide whether to wait for new
|
||||
/// events or close immediately.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TurnInfo {
|
||||
pub turn_id: String,
|
||||
pub file_path: String,
|
||||
pub library_id: i32,
|
||||
pub status: TurnStatus,
|
||||
pub total_events_pushed: u32,
|
||||
pub buffered_count: u32,
|
||||
}
|
||||
|
||||
/// Result of reading events at or after an absolute `skip_before` index.
|
||||
#[derive(Debug)]
|
||||
pub enum ReplayOutcome {
|
||||
/// New events are available. `next_skip` is the absolute index to pass
|
||||
/// on the next read (i.e. one past the last event returned).
|
||||
Events {
|
||||
events: Vec<ChatStreamEvent>,
|
||||
next_skip: u32,
|
||||
},
|
||||
/// The reader is caught up to the live edge — no events past `skip_before`
|
||||
/// yet. `next_skip` is the current high-water mark.
|
||||
CaughtUp { next_skip: u32 },
|
||||
/// `skip_before` points below the buffer's base index: the requested
|
||||
/// events were evicted. Maps to HTTP 410 Gone.
|
||||
Gone,
|
||||
}
|
||||
|
||||
/// Per-turn state shared between the agentic loop (writer) and all SSE
|
||||
/// replay connections (readers).
|
||||
pub struct TurnEntry {
|
||||
pub turn_id: String,
|
||||
pub file_path: String,
|
||||
pub library_id: i32,
|
||||
/// Shared event buffer — multiple SSE connections can read independently.
|
||||
/// Each connection tracks its own `skip_before` offset.
|
||||
events: Mutex<Vec<ChatStreamEvent>>,
|
||||
/// Monotonic counter: total events pushed (may exceed events.len()
|
||||
/// due to eviction). Used for skip_before indexing.
|
||||
total_events_pushed: AtomicU32,
|
||||
/// The event index that this entry started with. Adjusts on eviction
|
||||
/// so that `skip_before` stays absolute across connections.
|
||||
base_index: AtomicU32,
|
||||
pub status: AtomicU32,
|
||||
/// Abort handle for the spawned agentic task, set once after spawn.
|
||||
/// Behind a std `Mutex` because the entry is shared via `Arc` and the
|
||||
/// handle is installed after the entry is already in the registry.
|
||||
abort_handle: StdMutex<Option<AbortHandle>>,
|
||||
pub created_at: Instant,
|
||||
notify: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl TurnEntry {
|
||||
pub fn new(turn_id: String, file_path: String, library_id: i32) -> Self {
|
||||
Self {
|
||||
turn_id,
|
||||
file_path,
|
||||
library_id,
|
||||
events: Mutex::new(Vec::new()),
|
||||
total_events_pushed: AtomicU32::new(0),
|
||||
base_index: AtomicU32::new(0),
|
||||
status: AtomicU32::new(TurnStatus::Running as u32),
|
||||
abort_handle: StdMutex::new(None),
|
||||
created_at: Instant::now(),
|
||||
notify: Arc::new(Notify::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the abort handle for the spawned agentic task. Called once,
|
||||
/// right after the task is spawned.
|
||||
pub fn set_abort_handle(&self, handle: AbortHandle) {
|
||||
*self.abort_handle.lock().expect("abort_handle poisoned") = Some(handle);
|
||||
}
|
||||
|
||||
/// Abort the spawned agentic task, if a handle was installed. Returns
|
||||
/// `true` if a task was aborted.
|
||||
pub fn abort(&self) -> bool {
|
||||
if let Some(handle) = self
|
||||
.abort_handle
|
||||
.lock()
|
||||
.expect("abort_handle poisoned")
|
||||
.take()
|
||||
{
|
||||
handle.abort();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Push an event into the buffer. Evicts oldest events if the buffer
|
||||
/// exceeds `MAX_BUFFERED_EVENTS`. Notifies all waiting SSE connections.
|
||||
pub async fn push_event(&self, event: ChatStreamEvent) {
|
||||
{
|
||||
let mut events = self.events.lock().await;
|
||||
|
||||
// Evict oldest events if we've hit the cap.
|
||||
if events.len() >= MAX_BUFFERED_EVENTS {
|
||||
// Drop the oldest event to make room and advance the base
|
||||
// index so skip_before stays absolute across connections.
|
||||
events.remove(0);
|
||||
self.base_index.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
events.push(event);
|
||||
// Increment while holding the buffer lock so the counter stays in
|
||||
// lock-step with the buffer even if multiple writers ever exist.
|
||||
self.total_events_pushed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
self.notify.notify_waiters();
|
||||
}
|
||||
|
||||
/// Get a snapshot of turn metadata for the `turn_info` SSE event.
|
||||
pub async fn info(&self) -> TurnInfo {
|
||||
let events = self.events.lock().await;
|
||||
let buffered = events.len() as u32;
|
||||
let total = self.total_events_pushed.load(Ordering::Relaxed);
|
||||
drop(events);
|
||||
|
||||
TurnInfo {
|
||||
turn_id: self.turn_id.clone(),
|
||||
file_path: self.file_path.clone(),
|
||||
library_id: self.library_id,
|
||||
status: self.status.load(Ordering::Relaxed).into(),
|
||||
total_events_pushed: total,
|
||||
buffered_count: buffered,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the terminal status and notify all waiters.
|
||||
pub fn set_terminal_status(&self, status: TurnStatus) {
|
||||
self.status.store(status as u32, Ordering::Relaxed);
|
||||
self.notify.notify_waiters();
|
||||
}
|
||||
|
||||
/// Read buffered events at or after absolute index `skip_before` without
|
||||
/// waiting. Distinguishes "evicted" (Gone) from "caught up" (no new
|
||||
/// events yet) — the previous boolean/`Option` API conflated the two.
|
||||
pub async fn replay_from(&self, skip_before: u32) -> ReplayOutcome {
|
||||
let events = self.events.lock().await;
|
||||
let base = self.base_index.load(Ordering::Relaxed);
|
||||
|
||||
// The buffer holds absolute indices [base, base + len). A request
|
||||
// below `base` asked for events that have been evicted.
|
||||
if skip_before < base {
|
||||
return ReplayOutcome::Gone;
|
||||
}
|
||||
|
||||
let offset = (skip_before - base) as usize;
|
||||
let next_skip = base + events.len() as u32;
|
||||
if offset >= events.len() {
|
||||
// Caught up to (or past) the live edge — nothing new yet.
|
||||
return ReplayOutcome::CaughtUp { next_skip };
|
||||
}
|
||||
|
||||
ReplayOutcome::Events {
|
||||
events: events[offset..].to_vec(),
|
||||
next_skip,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the next batch of events past `skip_before`, the turn to
|
||||
/// finish, or eviction. Returns:
|
||||
/// - `Events` when new events are available (drained before any terminal
|
||||
/// signal so the final `Done`/`Error` is never dropped),
|
||||
/// - `CaughtUp` only when the turn has reached a terminal status and the
|
||||
/// reader is fully drained (the caller should close the stream),
|
||||
/// - `Gone` when `skip_before` points into evicted territory.
|
||||
pub async fn next_batch(&self, skip_before: u32) -> ReplayOutcome {
|
||||
loop {
|
||||
// Register interest BEFORE inspecting state so a push/terminal that
|
||||
// races between our read and our await can't be lost (Notify's
|
||||
// `notify_waiters` does not store a permit).
|
||||
let notified = self.notify.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
|
||||
match self.replay_from(skip_before).await {
|
||||
ReplayOutcome::CaughtUp { next_skip } => {
|
||||
// No new events. If the turn is finished, every event
|
||||
// (including the terminal one) has already been drained
|
||||
// above on a prior call, so signal the caller to close.
|
||||
if !self.is_running() {
|
||||
return ReplayOutcome::CaughtUp { next_skip };
|
||||
}
|
||||
// Still running — wait for the next push or terminal.
|
||||
}
|
||||
other => return other, // Events or Gone
|
||||
}
|
||||
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this turn is still running.
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.status.load(Ordering::Relaxed) == TurnStatus::Running as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory registry of all active chat turns. Injected into `AppState`
|
||||
/// and shared across all handlers.
|
||||
pub struct TurnRegistry {
|
||||
entries: Mutex<HashMap<String, Arc<TurnEntry>>>,
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl TurnRegistry {
|
||||
pub fn new(timeout_secs: u64) -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
timeout_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the cleanup timeout in seconds.
|
||||
pub fn timeout_secs(&self) -> u64 {
|
||||
self.timeout_secs
|
||||
}
|
||||
|
||||
/// Insert a new turn entry. Returns the turn_id.
|
||||
pub async fn insert(&self, entry: Arc<TurnEntry>) -> String {
|
||||
let turn_id = entry.turn_id.clone();
|
||||
let mut entries = self.entries.lock().await;
|
||||
entries.insert(turn_id.clone(), entry);
|
||||
turn_id
|
||||
}
|
||||
|
||||
/// Look up a turn by id. Returns None if not found or expired.
|
||||
pub async fn get(&self, turn_id: &str) -> Option<Arc<TurnEntry>> {
|
||||
let entries = self.entries.lock().await;
|
||||
entries.get(turn_id).cloned()
|
||||
}
|
||||
|
||||
/// Clean up stale entries older than the timeout. Returns the count of
|
||||
/// entries removed.
|
||||
pub async fn cleanup_stale(&self) -> usize {
|
||||
let mut entries = self.entries.lock().await;
|
||||
let _now = Instant::now();
|
||||
let stale: Vec<String> = entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.created_at.elapsed().as_secs() > self.timeout_secs)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
|
||||
for id in &stale {
|
||||
entries.remove(id);
|
||||
}
|
||||
|
||||
if !stale.is_empty() {
|
||||
log::info!(
|
||||
"TurnRegistry: cleaned up {} stale entries (timeout={}s)",
|
||||
stale.len(),
|
||||
self.timeout_secs
|
||||
);
|
||||
}
|
||||
|
||||
stale.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai::insight_chat::ChatStreamEvent;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Unwrap the events from a `ReplayOutcome::Events`, panicking otherwise.
|
||||
fn events_of(outcome: ReplayOutcome) -> Vec<ChatStreamEvent> {
|
||||
match outcome {
|
||||
ReplayOutcome::Events { events, .. } => events,
|
||||
other => panic!("expected Events, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── TurnStatus ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn turn_status_from_u32_valid_values() {
|
||||
assert_eq!(TurnStatus::from(0), TurnStatus::Running);
|
||||
assert_eq!(TurnStatus::from(1), TurnStatus::Done);
|
||||
assert_eq!(TurnStatus::from(2), TurnStatus::Error);
|
||||
assert_eq!(TurnStatus::from(3), TurnStatus::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_status_from_u32_unknown_defaults_to_running() {
|
||||
assert_eq!(TurnStatus::from(4), TurnStatus::Running);
|
||||
assert_eq!(TurnStatus::from(u32::MAX), TurnStatus::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_status_as_str() {
|
||||
assert_eq!(TurnStatus::Running.as_str(), "running");
|
||||
assert_eq!(TurnStatus::Done.as_str(), "done");
|
||||
assert_eq!(TurnStatus::Error.as_str(), "error");
|
||||
assert_eq!(TurnStatus::Cancelled.as_str(), "cancelled");
|
||||
}
|
||||
|
||||
// ── TurnEntry ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_entry_push_and_replay() {
|
||||
let entry = Arc::new(TurnEntry::new(
|
||||
"t1".to_string(),
|
||||
"/photo.jpg".to_string(),
|
||||
1,
|
||||
));
|
||||
|
||||
entry
|
||||
.push_event(ChatStreamEvent::TextDelta("hello".to_string()))
|
||||
.await;
|
||||
entry
|
||||
.push_event(ChatStreamEvent::TextDelta(" world".to_string()))
|
||||
.await;
|
||||
|
||||
let events = events_of(entry.replay_from(0).await);
|
||||
assert_eq!(events.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_entry_replay_with_skip() {
|
||||
let entry = Arc::new(TurnEntry::new(
|
||||
"t1".to_string(),
|
||||
"/photo.jpg".to_string(),
|
||||
1,
|
||||
));
|
||||
|
||||
for i in 0..5 {
|
||||
entry
|
||||
.push_event(ChatStreamEvent::TextDelta(format!("e{i}")))
|
||||
.await;
|
||||
}
|
||||
|
||||
// skip_before=0 → all 5 events
|
||||
let all = events_of(entry.replay_from(0).await);
|
||||
assert_eq!(all.len(), 5);
|
||||
|
||||
// skip_before=2 → events 2,3,4 (3 events)
|
||||
let skipped = events_of(entry.replay_from(2).await);
|
||||
assert_eq!(skipped.len(), 3);
|
||||
|
||||
// skip_before=5 → caught up to the live edge (not Gone).
|
||||
assert!(matches!(
|
||||
entry.replay_from(5).await,
|
||||
ReplayOutcome::CaughtUp { next_skip: 5 }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_entry_replay_empty_by_default() {
|
||||
let entry = Arc::new(TurnEntry::new(
|
||||
"t1".to_string(),
|
||||
"/photo.jpg".to_string(),
|
||||
1,
|
||||
));
|
||||
// Empty buffer with skip_before=0 → caught up (nothing to replay yet).
|
||||
assert!(matches!(
|
||||
entry.replay_from(0).await,
|
||||
ReplayOutcome::CaughtUp { next_skip: 0 }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_entry_is_running_initially() {
|
||||
let entry = TurnEntry::new("t1".to_string(), "/photo.jpg".to_string(), 1);
|
||||
assert!(entry.is_running());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_entry_set_terminal_status() {
|
||||
let entry = Arc::new(TurnEntry::new(
|
||||
"t1".to_string(),
|
||||
"/photo.jpg".to_string(),
|
||||
1,
|
||||
));
|
||||
assert!(entry.is_running());
|
||||
entry.set_terminal_status(TurnStatus::Done);
|
||||
assert!(!entry.is_running());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_entry_info() {
|
||||
let entry = Arc::new(TurnEntry::new(
|
||||
"t1".to_string(),
|
||||
"/photo.jpg".to_string(),
|
||||
42,
|
||||
));
|
||||
|
||||
entry
|
||||
.push_event(ChatStreamEvent::TextDelta("x".to_string()))
|
||||
.await;
|
||||
entry.set_terminal_status(TurnStatus::Done);
|
||||
|
||||
let info = entry.info().await;
|
||||
assert_eq!(info.turn_id, "t1");
|
||||
assert_eq!(info.file_path, "/photo.jpg");
|
||||
assert_eq!(info.library_id, 42);
|
||||
assert_eq!(info.status, TurnStatus::Done);
|
||||
assert_eq!(info.total_events_pushed, 1);
|
||||
assert_eq!(info.buffered_count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_entry_eviction_caps_buffer() {
|
||||
let entry = Arc::new(TurnEntry::new(
|
||||
"t1".to_string(),
|
||||
"/photo.jpg".to_string(),
|
||||
1,
|
||||
));
|
||||
|
||||
// Push MAX_BUFFERED_EVENTS + 10 events.
|
||||
for i in 0..(MAX_BUFFERED_EVENTS + 10) {
|
||||
entry
|
||||
.push_event(ChatStreamEvent::TextDelta(format!("e{i}")))
|
||||
.await;
|
||||
}
|
||||
|
||||
// Asking from absolute 0 after eviction is Gone (0-9 were dropped).
|
||||
assert!(matches!(entry.replay_from(0).await, ReplayOutcome::Gone));
|
||||
|
||||
// Reading from the new base (10) returns the full capped buffer.
|
||||
let events = events_of(entry.replay_from(10).await);
|
||||
assert_eq!(events.len(), MAX_BUFFERED_EVENTS);
|
||||
|
||||
// First event should be at index 10 (0-9 were evicted).
|
||||
if let ChatStreamEvent::TextDelta(s) = &events[0] {
|
||||
assert_eq!(s, "e10");
|
||||
} else {
|
||||
panic!("expected TextDelta");
|
||||
}
|
||||
|
||||
// Last event should be at index MAX_BUFFERED_EVENTS + 9.
|
||||
if let ChatStreamEvent::TextDelta(s) = &events[events.len() - 1] {
|
||||
assert_eq!(s, &format!("e{}", MAX_BUFFERED_EVENTS + 9));
|
||||
} else {
|
||||
panic!("expected TextDelta");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_entry_replay_evicted_index_is_gone() {
|
||||
let entry = Arc::new(TurnEntry::new(
|
||||
"t1".to_string(),
|
||||
"/photo.jpg".to_string(),
|
||||
1,
|
||||
));
|
||||
|
||||
// Push one past the cap so exactly one event (index 0) is evicted.
|
||||
for i in 0..=MAX_BUFFERED_EVENTS {
|
||||
entry
|
||||
.push_event(ChatStreamEvent::TextDelta(format!("e{i}")))
|
||||
.await;
|
||||
}
|
||||
|
||||
// Base is now 1; asking from absolute 0 is evicted territory → Gone.
|
||||
assert!(matches!(entry.replay_from(0).await, ReplayOutcome::Gone));
|
||||
|
||||
// skip_before = MAX_BUFFERED_EVENTS → last event only (index valid).
|
||||
let last = events_of(entry.replay_from(MAX_BUFFERED_EVENTS as u32).await);
|
||||
assert_eq!(last.len(), 1);
|
||||
|
||||
// skip_before = MAX_BUFFERED_EVENTS + 1 → caught up to the live edge.
|
||||
assert!(matches!(
|
||||
entry.replay_from((MAX_BUFFERED_EVENTS + 1) as u32).await,
|
||||
ReplayOutcome::CaughtUp { .. }
|
||||
));
|
||||
}
|
||||
|
||||
// ── TurnRegistry ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_registry_insert_and_get() {
|
||||
let registry = TurnRegistry::new(300);
|
||||
let entry = Arc::new(TurnEntry::new(
|
||||
"t1".to_string(),
|
||||
"/photo.jpg".to_string(),
|
||||
1,
|
||||
));
|
||||
let id = registry.insert(entry).await;
|
||||
assert_eq!(id, "t1");
|
||||
|
||||
let retrieved = registry.get("t1").await;
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().turn_id, "t1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_registry_get_nonexistent_returns_none() {
|
||||
let registry = TurnRegistry::new(300);
|
||||
assert!(registry.get("nonexistent").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_registry_cleanup_stale_removes_old_entries() {
|
||||
let registry = TurnRegistry::new(0);
|
||||
let mut entry = TurnEntry::new("t1".to_string(), "/photo.jpg".to_string(), 1);
|
||||
entry.created_at = Instant::now() - Duration::from_secs(1);
|
||||
registry.insert(Arc::new(entry)).await;
|
||||
|
||||
let cleaned = registry.cleanup_stale().await;
|
||||
assert_eq!(cleaned, 1);
|
||||
assert!(registry.get("t1").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_registry_cleanup_stale_preserves_recent() {
|
||||
let registry = TurnRegistry::new(3600); // 1 hour
|
||||
let entry = Arc::new(TurnEntry::new(
|
||||
"t1".to_string(),
|
||||
"/photo.jpg".to_string(),
|
||||
1,
|
||||
));
|
||||
registry.insert(entry).await;
|
||||
|
||||
let cleaned = registry.cleanup_stale().await;
|
||||
assert_eq!(cleaned, 0);
|
||||
assert!(registry.get("t1").await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_registry_cleanup_stale_multiple() {
|
||||
let registry = TurnRegistry::new(0);
|
||||
|
||||
for i in 0..5 {
|
||||
let mut entry = TurnEntry::new(format!("t{i}"), "/photo.jpg".to_string(), 1);
|
||||
entry.created_at = Instant::now() - Duration::from_secs(1);
|
||||
registry.insert(Arc::new(entry)).await;
|
||||
}
|
||||
|
||||
let cleaned = registry.cleanup_stale().await;
|
||||
assert_eq!(cleaned, 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_registry_timeout_secs() {
|
||||
let registry = TurnRegistry::new(600);
|
||||
assert_eq!(registry.timeout_secs(), 600);
|
||||
}
|
||||
|
||||
// ── next_batch / live replay ────────────────────────────────────
|
||||
|
||||
/// Drain a turn the way the SSE replay handler does: pull batches via
|
||||
/// `next_batch` until the turn is finished and fully drained.
|
||||
async fn drain_to_end(entry: Arc<TurnEntry>) -> Vec<ChatStreamEvent> {
|
||||
let mut out = Vec::new();
|
||||
let mut skip = 0u32;
|
||||
while let ReplayOutcome::Events { events, next_skip } = entry.next_batch(skip).await {
|
||||
out.extend(events);
|
||||
skip = next_skip;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_terminal(ev: &ChatStreamEvent) -> bool {
|
||||
matches!(ev, ChatStreamEvent::Done { .. } | ChatStreamEvent::Error(_))
|
||||
}
|
||||
|
||||
/// The core guarantee behind the replay rewrite: a reader waiting on
|
||||
/// `next_batch` always receives the terminal event, even though the
|
||||
/// writer flips status to terminal immediately after pushing it.
|
||||
#[tokio::test]
|
||||
async fn next_batch_always_delivers_terminal_event() {
|
||||
for _ in 0..50 {
|
||||
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
|
||||
|
||||
let writer = entry.clone();
|
||||
let w = tokio::spawn(async move {
|
||||
writer
|
||||
.push_event(ChatStreamEvent::IterationStart { n: 1, max: 6 })
|
||||
.await;
|
||||
writer
|
||||
.push_event(ChatStreamEvent::TextDelta("hi".into()))
|
||||
.await;
|
||||
// Push terminal then flip status with no await between — the
|
||||
// race that previously dropped the Done on the reader side.
|
||||
writer
|
||||
.push_event(ChatStreamEvent::Done {
|
||||
tool_calls_made: 0,
|
||||
iterations_used: 1,
|
||||
truncated: false,
|
||||
prompt_tokens: None,
|
||||
eval_tokens: None,
|
||||
num_ctx: None,
|
||||
amended_insight_id: None,
|
||||
backend_used: "local".into(),
|
||||
model_used: "m".into(),
|
||||
cancelled: false,
|
||||
})
|
||||
.await;
|
||||
writer.set_terminal_status(TurnStatus::Done);
|
||||
});
|
||||
|
||||
let events = drain_to_end(entry).await;
|
||||
w.await.unwrap();
|
||||
|
||||
assert!(
|
||||
events.last().is_some_and(is_terminal),
|
||||
"terminal event missing; got {} events",
|
||||
events.len()
|
||||
);
|
||||
assert_eq!(events.len(), 3, "expected IterationStart, TextDelta, Done");
|
||||
}
|
||||
}
|
||||
|
||||
/// A reader that connects before any event is pushed blocks in
|
||||
/// `next_batch` and then receives events as the writer produces them.
|
||||
#[tokio::test]
|
||||
async fn next_batch_waits_for_late_events() {
|
||||
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
|
||||
|
||||
let writer = entry.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::task::yield_now().await;
|
||||
writer
|
||||
.push_event(ChatStreamEvent::TextDelta("late".into()))
|
||||
.await;
|
||||
writer.set_terminal_status(TurnStatus::Done);
|
||||
});
|
||||
|
||||
// First call blocks until the writer pushes, rather than returning
|
||||
// CaughtUp on the empty buffer of a running turn.
|
||||
match entry.next_batch(0).await {
|
||||
ReplayOutcome::Events { events, next_skip } => {
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(next_skip, 1);
|
||||
}
|
||||
other => panic!("expected Events, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn next_batch_closes_on_terminal_when_caught_up() {
|
||||
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
|
||||
entry
|
||||
.push_event(ChatStreamEvent::TextDelta("x".into()))
|
||||
.await;
|
||||
entry.set_terminal_status(TurnStatus::Done);
|
||||
|
||||
// Caught up (skip past the one buffered event) on a finished turn →
|
||||
// CaughtUp so the handler closes the stream rather than hanging.
|
||||
assert!(matches!(
|
||||
entry.next_batch(1).await,
|
||||
ReplayOutcome::CaughtUp { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn next_batch_reports_gone_for_evicted_index() {
|
||||
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
|
||||
for i in 0..=MAX_BUFFERED_EVENTS {
|
||||
entry
|
||||
.push_event(ChatStreamEvent::TextDelta(format!("e{i}")))
|
||||
.await;
|
||||
}
|
||||
// Index 0 was evicted (base advanced to 1).
|
||||
assert!(matches!(entry.next_batch(0).await, ReplayOutcome::Gone));
|
||||
}
|
||||
|
||||
// ── abort handle (#1 cancellation) ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn abort_handle_aborts_task_once() {
|
||||
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
|
||||
|
||||
// No handle installed yet → abort is a no-op.
|
||||
assert!(!entry.abort());
|
||||
|
||||
let handle = tokio::spawn(async {
|
||||
// Long-lived task that only ends via abort.
|
||||
futures::future::pending::<()>().await;
|
||||
});
|
||||
entry.set_abort_handle(handle.abort_handle());
|
||||
|
||||
assert!(entry.abort(), "first abort should fire");
|
||||
assert!(!entry.abort(), "handle is taken; second abort is a no-op");
|
||||
|
||||
// The aborted task resolves to a cancellation JoinError.
|
||||
let join = handle.await;
|
||||
assert!(join.unwrap_err().is_cancelled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_index_tracks_eviction() {
|
||||
let entry = Arc::new(TurnEntry::new("t".into(), "/p.jpg".into(), 1));
|
||||
for i in 0..(MAX_BUFFERED_EVENTS + 5) {
|
||||
entry
|
||||
.push_event(ChatStreamEvent::TextDelta(format!("e{i}")))
|
||||
.await;
|
||||
}
|
||||
let info = entry.info().await;
|
||||
// 5 events evicted; total keeps climbing, buffer stays capped.
|
||||
assert_eq!(info.total_events_pushed, (MAX_BUFFERED_EVENTS + 5) as u32);
|
||||
assert_eq!(info.buffered_count, MAX_BUFFERED_EVENTS as u32);
|
||||
// First live index is 5: reading from there yields the full buffer.
|
||||
let from_base = events_of(entry.replay_from(5).await);
|
||||
assert_eq!(from_base.len(), MAX_BUFFERED_EVENTS);
|
||||
}
|
||||
}
|
||||
+5
-80
@@ -220,76 +220,6 @@ pub fn backfill_missing_date_taken(
|
||||
/// unscanned image_exif rows directly via the FaceDao anti-join and
|
||||
/// hands them to the existing detection pass. Runs on every tick (not
|
||||
/// just full scans) so the backlog moves at quick-scan cadence.
|
||||
/// Per-tick CLIP encoding drain. Mirrors `process_face_backlog`: pull
|
||||
/// up to `CLIP_BACKLOG_MAX_PER_TICK` candidates with a known
|
||||
/// `content_hash` but no `clip_embedding`, hand them to
|
||||
/// `clip_watch::run_clip_encoding_pass` for parallel fan-out, and let
|
||||
/// that module write the result back via `backfill_clip_embedding`.
|
||||
///
|
||||
/// Idempotent — a row stays in the candidate set until its embedding
|
||||
/// lands, so a transient failure (Apollo unreachable, CUDA OOM) just
|
||||
/// defers to the next tick. Permanent failures (un-decodable bytes)
|
||||
/// retry every tick at this point; future Branch may add a status
|
||||
/// column like face_detections has.
|
||||
pub fn process_clip_backlog(
|
||||
context: &opentelemetry::Context,
|
||||
library: &libraries::Library,
|
||||
clip_client: &crate::ai::clip_client::ClipClient,
|
||||
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
excluded_dirs: &[String],
|
||||
) {
|
||||
if !clip_client.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let cap: i64 = dotenv::var("CLIP_BACKLOG_MAX_PER_TICK")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.filter(|n: &i64| *n > 0)
|
||||
.unwrap_or(32);
|
||||
|
||||
let rows: Vec<(String, String)> = {
|
||||
let mut dao = exif_dao.lock().expect("exif dao");
|
||||
match dao.list_clip_unencoded_candidates(context, library.id, cap) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"clip_watch: list_clip_unencoded_candidates failed for library '{}': {:?}",
|
||||
library.name, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"clip_watch: backlog drain — encoding {} candidate(s) for library '{}' (cap={})",
|
||||
rows.len(),
|
||||
library.name,
|
||||
cap
|
||||
);
|
||||
|
||||
let candidates: Vec<crate::clip_watch::ClipCandidate> = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(rel_path, content_hash)| crate::clip_watch::ClipCandidate {
|
||||
rel_path,
|
||||
content_hash,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
crate::clip_watch::run_clip_encoding_pass(
|
||||
library,
|
||||
excluded_dirs,
|
||||
clip_client,
|
||||
Arc::clone(exif_dao),
|
||||
candidates,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn process_face_backlog(
|
||||
context: &opentelemetry::Context,
|
||||
library: &libraries::Library,
|
||||
@@ -529,21 +459,16 @@ mod tests {
|
||||
opentelemetry::Context::new()
|
||||
}
|
||||
|
||||
/// Everything `setup` hands back to a test: tempdir, library, shared
|
||||
/// connection, and the two DAOs. Aliased to keep clippy's
|
||||
/// type-complexity lint satisfied.
|
||||
type SetupFixture = (
|
||||
/// Build a tempdir-backed library + DAOs sharing a single in-memory
|
||||
/// SQLite connection (so cross-table joins like
|
||||
/// `list_unscanned_candidates` see consistent state).
|
||||
fn setup() -> (
|
||||
TempDir,
|
||||
Library,
|
||||
Arc<Mutex<diesel::SqliteConnection>>,
|
||||
Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
Arc<Mutex<Box<dyn FaceDao>>>,
|
||||
);
|
||||
|
||||
/// Build a tempdir-backed library + DAOs sharing a single in-memory
|
||||
/// SQLite connection (so cross-table joins like
|
||||
/// `list_unscanned_candidates` see consistent state).
|
||||
fn setup() -> SetupFixture {
|
||||
) {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let mut conn = in_memory_db_connection();
|
||||
// Migration seeds library id=1 with a placeholder root; rewrite it
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use clap::Parser;
|
||||
use image_api::ai::LocalLlm;
|
||||
use image_api::ai::ollama::OllamaClient;
|
||||
use image_api::bin_progress;
|
||||
use image_api::database::calendar_dao::{InsertCalendarEvent, SqliteCalendarEventDao};
|
||||
use image_api::parsers::ical_parser::parse_ics_file;
|
||||
@@ -44,10 +44,22 @@ async fn main() -> Result<()> {
|
||||
|
||||
let context = opentelemetry::Context::current();
|
||||
|
||||
// LocalLlm dispatches per LLM_BACKEND, so embeddings written here land
|
||||
// in the same vector space the query side searches.
|
||||
let llm = if args.generate_embeddings {
|
||||
Some(LocalLlm::from_env())
|
||||
let ollama = if args.generate_embeddings {
|
||||
let primary_url = dotenv::var("OLLAMA_PRIMARY_URL")
|
||||
.or_else(|_| dotenv::var("OLLAMA_URL"))
|
||||
.unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let fallback_url = dotenv::var("OLLAMA_FALLBACK_URL").ok();
|
||||
let primary_model = dotenv::var("OLLAMA_PRIMARY_MODEL")
|
||||
.or_else(|_| dotenv::var("OLLAMA_MODEL"))
|
||||
.unwrap_or_else(|_| "nomic-embed-text:v1.5".to_string());
|
||||
let fallback_model = dotenv::var("OLLAMA_FALLBACK_MODEL").ok();
|
||||
|
||||
Some(OllamaClient::new(
|
||||
primary_url,
|
||||
fallback_url,
|
||||
primary_model,
|
||||
fallback_model,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -78,7 +90,7 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
// Generate embedding if requested (blocking call)
|
||||
let embedding = if let Some(ref llm) = llm {
|
||||
let embedding = if let Some(ref ollama_client) = ollama {
|
||||
let text = format!(
|
||||
"{} {} {}",
|
||||
event.summary,
|
||||
@@ -88,7 +100,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
match tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current()
|
||||
.block_on(async { llm.embed_document(&text).await })
|
||||
.block_on(async { ollama_client.generate_embedding(&text).await })
|
||||
}) {
|
||||
Ok(emb) => Some(emb),
|
||||
Err(e) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use clap::Parser;
|
||||
use image_api::ai::LocalLlm;
|
||||
use image_api::ai::ollama::OllamaClient;
|
||||
use image_api::bin_progress;
|
||||
use image_api::database::search_dao::{InsertSearchRecord, SqliteSearchHistoryDao};
|
||||
use image_api::parsers::search_html_parser::parse_search_html;
|
||||
@@ -38,9 +38,16 @@ async fn main() -> Result<()> {
|
||||
|
||||
info!("Found {} search records", searches.len());
|
||||
|
||||
// LocalLlm dispatches per LLM_BACKEND, so embeddings written here land
|
||||
// in the same vector space the query side searches.
|
||||
let llm = LocalLlm::from_env();
|
||||
let primary_url = dotenv::var("OLLAMA_PRIMARY_URL")
|
||||
.or_else(|_| dotenv::var("OLLAMA_URL"))
|
||||
.unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let fallback_url = dotenv::var("OLLAMA_FALLBACK_URL").ok();
|
||||
let primary_model = dotenv::var("OLLAMA_PRIMARY_MODEL")
|
||||
.or_else(|_| dotenv::var("OLLAMA_MODEL"))
|
||||
.unwrap_or_else(|_| "nomic-embed-text:v1.5".to_string());
|
||||
let fallback_model = dotenv::var("OLLAMA_FALLBACK_MODEL").ok();
|
||||
|
||||
let ollama = OllamaClient::new(primary_url, fallback_url, primary_model, fallback_model);
|
||||
let context = opentelemetry::Context::current();
|
||||
|
||||
let mut inserted_count = 0usize;
|
||||
@@ -60,11 +67,12 @@ async fn main() -> Result<()> {
|
||||
|
||||
let pb_for_warn = pb.clone();
|
||||
let embeddings_result = tokio::task::spawn({
|
||||
let llm = llm.clone();
|
||||
let ollama_client = ollama.clone();
|
||||
async move {
|
||||
// Generate embeddings in parallel for the batch
|
||||
let mut embeddings = Vec::new();
|
||||
for query in &queries {
|
||||
match llm.embed_document(query).await {
|
||||
match ollama_client.generate_embedding(query).await {
|
||||
Ok(emb) => embeddings.push(Some(emb)),
|
||||
Err(e) => {
|
||||
pb_for_warn.println(format!("embedding failed for '{}': {}", query, e));
|
||||
|
||||
@@ -195,7 +195,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
let generator = InsightGenerator::new(
|
||||
ollama,
|
||||
None,
|
||||
None,
|
||||
sms_client,
|
||||
apollo_client,
|
||||
insight_dao.clone(),
|
||||
@@ -336,7 +335,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
args.top_p,
|
||||
args.top_k,
|
||||
args.min_p,
|
||||
None, // enable_thinking: leave model/template default
|
||||
args.max_iterations,
|
||||
None,
|
||||
Vec::new(),
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Probe binary for RAM++ auto-tagging.
|
||||
//!
|
||||
//! No DB writes. Walks a library's `image_exif` rows, sends a sample
|
||||
//! through Apollo's `/api/internal/tags/auto`, and prints `(path, tags)`
|
||||
//! to stdout so the operator can eyeball whether the model's vocabulary
|
||||
//! and threshold defaults are appropriate for this library before
|
||||
//! committing to the persistence phase (new table, per-tick drain, UI).
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --release --bin probe_auto_tags -- \
|
||||
//! --library 1 --limit 50 --threshold 0.7
|
||||
//!
|
||||
//! Env: standard ImageApi `.env`. Requires either
|
||||
//! `APOLLO_TAG_API_BASE_URL` or `APOLLO_API_BASE_URL` to be set
|
||||
//! (otherwise the client is disabled and the probe bails).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use clap::Parser;
|
||||
use log::{info, warn};
|
||||
|
||||
use image_api::ai::tag_client::{TagClient, TagDetectError, TagMeta};
|
||||
use image_api::database::{ExifDao, SqliteExifDao, connect};
|
||||
use image_api::exif;
|
||||
use image_api::file_types;
|
||||
use image_api::libraries::{self, Library};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "probe_auto_tags")]
|
||||
#[command(about = "Print RAM++ auto-tags for a sample of image_exif rows")]
|
||||
struct Args {
|
||||
/// Library id to sample from.
|
||||
#[arg(long)]
|
||||
library: i32,
|
||||
|
||||
/// Max files to probe. The binary scans more rows internally because
|
||||
/// non-image rows (videos, junk) are skipped client-side.
|
||||
#[arg(long, default_value_t = 25)]
|
||||
limit: usize,
|
||||
|
||||
/// Per-call threshold sent to Apollo. Overrides the engine default.
|
||||
/// Lower = more tags per photo, more noise. 0.5–0.75 is the useful
|
||||
/// sweep range for ram_plus_swin_large_14m.
|
||||
#[arg(long, default_value_t = 0.65)]
|
||||
threshold: f32,
|
||||
|
||||
/// Offset into the library's rel_path listing (sorted by id ASC).
|
||||
/// Bump on re-runs to sample a different slice.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
offset: i64,
|
||||
|
||||
/// How many DB rows to scan before giving up on hitting the limit.
|
||||
/// Useful when a library is mostly videos.
|
||||
#[arg(long, default_value_t = 2000)]
|
||||
max_scan: i64,
|
||||
}
|
||||
|
||||
/// Mirror of `face_watch::read_image_bytes_for_detect` — it's pub(crate)
|
||||
/// so we can't import it across the bin boundary. The probe is throwaway
|
||||
/// scope; inlining is cleaner than changing the visibility.
|
||||
fn read_image_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||
if file_types::needs_ffmpeg_thumbnail(path)
|
||||
&& let Some(preview) = exif::extract_embedded_jpeg_preview(path)
|
||||
{
|
||||
return Ok(preview);
|
||||
}
|
||||
std::fs::read(path)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
env_logger::init();
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
let client = TagClient::from_env();
|
||||
if !client.is_enabled() {
|
||||
anyhow::bail!(
|
||||
"TagClient disabled: set APOLLO_TAG_API_BASE_URL or APOLLO_API_BASE_URL in .env"
|
||||
);
|
||||
}
|
||||
|
||||
// Quick health probe so we fail fast on a misconfig before grinding
|
||||
// through a thousand rows.
|
||||
match client.health().await {
|
||||
Ok(h) => info!(
|
||||
"tag engine: loaded={} device={} model={} threshold_default={}",
|
||||
h.loaded, h.device, h.model_version, h.threshold
|
||||
),
|
||||
Err(e) => warn!("health probe failed (continuing): {e}"),
|
||||
}
|
||||
|
||||
let mut seed_conn = connect();
|
||||
if let Some(base) = dotenv::var("BASE_PATH").ok().as_deref() {
|
||||
libraries::seed_or_patch_from_env(&mut seed_conn, base);
|
||||
}
|
||||
let libs = libraries::load_all(&mut seed_conn);
|
||||
drop(seed_conn);
|
||||
let lib: Library = libs
|
||||
.into_iter()
|
||||
.find(|l| l.id == args.library)
|
||||
.ok_or_else(|| anyhow::anyhow!("library id {} not found", args.library))?;
|
||||
info!("probing library #{} ({}) at {}", lib.id, lib.name, lib.root_path);
|
||||
|
||||
let dao: Arc<Mutex<Box<dyn ExifDao>>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
|
||||
let ctx = opentelemetry::Context::new();
|
||||
|
||||
// Paginate through (id, rel_path) for this library, filter to images
|
||||
// on disk, take `limit`. Page size is tuned so we don't slam the DB
|
||||
// when a library is video-heavy.
|
||||
const PAGE: i64 = 500;
|
||||
let mut offset = args.offset;
|
||||
let mut scanned: i64 = 0;
|
||||
let mut probed = 0usize;
|
||||
let mut ok_count = 0usize;
|
||||
let mut empty_count = 0usize;
|
||||
let mut perm_fail = 0usize;
|
||||
let mut transient_fail = 0usize;
|
||||
let started = Instant::now();
|
||||
let root = PathBuf::from(&lib.root_path);
|
||||
|
||||
'outer: loop {
|
||||
if scanned >= args.max_scan {
|
||||
warn!(
|
||||
"scan cap ({}) reached before hitting limit ({}); bump --max-scan to scan deeper",
|
||||
args.max_scan, args.limit
|
||||
);
|
||||
break;
|
||||
}
|
||||
let rows = {
|
||||
let mut guard = dao.lock().expect("dao lock");
|
||||
guard
|
||||
.list_rel_paths_for_library_page(&ctx, lib.id, PAGE, offset)
|
||||
.map_err(|e| anyhow::anyhow!("list rel_paths: {:?}", e))?
|
||||
};
|
||||
if rows.is_empty() {
|
||||
info!("no more rows after offset {}", offset);
|
||||
break;
|
||||
}
|
||||
offset += rows.len() as i64;
|
||||
scanned += rows.len() as i64;
|
||||
|
||||
for (_id, rel_path) in rows {
|
||||
if probed >= args.limit {
|
||||
break 'outer;
|
||||
}
|
||||
let abs = root.join(&rel_path);
|
||||
// Skip non-images and videos at the path level — same logic
|
||||
// the face backlog drain uses, just inlined.
|
||||
if !file_types::is_image_file(&abs) {
|
||||
continue;
|
||||
}
|
||||
if !abs.exists() {
|
||||
continue;
|
||||
}
|
||||
let bytes = match read_image_bytes(&abs) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("read {rel_path}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// The probe doesn't need a real content_hash — Apollo only
|
||||
// logs it. Pass an empty marker so we don't trip on no-hash
|
||||
// image_exif rows.
|
||||
let meta = TagMeta {
|
||||
content_hash: String::new(),
|
||||
library_id: lib.id,
|
||||
rel_path: rel_path.clone(),
|
||||
threshold: Some(args.threshold),
|
||||
};
|
||||
|
||||
let call_start = Instant::now();
|
||||
match client.auto_tag(bytes, meta).await {
|
||||
Ok(resp) => {
|
||||
probed += 1;
|
||||
if resp.tags.is_empty() {
|
||||
empty_count += 1;
|
||||
println!(
|
||||
"[{:>3}] (no tags) {}ms {}",
|
||||
probed, resp.duration_ms, rel_path
|
||||
);
|
||||
} else {
|
||||
ok_count += 1;
|
||||
let preview = resp
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| format!("{}({:.2})", t.name, t.confidence))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
println!(
|
||||
"[{:>3}] {} tags {}ms {}\n {}",
|
||||
probed,
|
||||
resp.tags.len(),
|
||||
resp.duration_ms,
|
||||
rel_path,
|
||||
preview
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(TagDetectError::Permanent(e)) => {
|
||||
probed += 1;
|
||||
perm_fail += 1;
|
||||
println!(
|
||||
"[{:>3}] PERMANENT FAIL ({:>4}ms) {}\n {}",
|
||||
probed,
|
||||
call_start.elapsed().as_millis(),
|
||||
rel_path,
|
||||
e
|
||||
);
|
||||
}
|
||||
Err(TagDetectError::Transient(e)) => {
|
||||
probed += 1;
|
||||
transient_fail += 1;
|
||||
println!(
|
||||
"[{:>3}] TRANSIENT FAIL ({:>4}ms) {}\n {}",
|
||||
probed,
|
||||
call_start.elapsed().as_millis(),
|
||||
rel_path,
|
||||
e
|
||||
);
|
||||
}
|
||||
Err(TagDetectError::Disabled) => {
|
||||
anyhow::bail!("tag client became disabled mid-run; impossible");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = started.elapsed();
|
||||
println!();
|
||||
println!("── summary ───────────────────────────────────────");
|
||||
println!("scanned rows : {scanned}");
|
||||
println!("probed files : {probed}");
|
||||
println!(" with tags : {ok_count}");
|
||||
println!(" empty (no tags) : {empty_count}");
|
||||
println!(" permanent failures : {perm_fail}");
|
||||
println!(" transient failures : {transient_fail}");
|
||||
println!("elapsed : {:.1}s", elapsed.as_secs_f32());
|
||||
if probed > 0 {
|
||||
println!(
|
||||
"throughput : {:.2} photos/s",
|
||||
probed as f32 / elapsed.as_secs_f32().max(0.001)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
//! Probe binary for CLIP semantic search.
|
||||
//!
|
||||
//! No DB writes. Walks a library's `image_exif` rows, encodes a sample
|
||||
//! via Apollo's `/encode_image`, encodes the user's --query via
|
||||
//! `/encode_text`, and prints the top-K most similar photos by cosine
|
||||
//! similarity so the operator can eyeball quality before committing to
|
||||
//! the persistence phase (column populated by backlog drain, search
|
||||
//! endpoint, UI).
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --release --bin probe_clip_search -- \
|
||||
//! --library 1 --limit 200 --query "a beach at sunset" --top 10
|
||||
//!
|
||||
//! Env: standard ImageApi `.env`. Requires either
|
||||
//! `APOLLO_CLIP_API_BASE_URL` or `APOLLO_API_BASE_URL` to be set.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use clap::Parser;
|
||||
use log::{info, warn};
|
||||
|
||||
use image_api::ai::clip_client::{ClipClient, ClipError, EncodeImageMeta};
|
||||
use image_api::database::{ExifDao, SqliteExifDao, connect};
|
||||
use image_api::exif;
|
||||
use image_api::file_types;
|
||||
use image_api::libraries::{self, Library};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "probe_clip_search")]
|
||||
#[command(about = "Top-K CLIP semantic search over a sample of image_exif rows")]
|
||||
struct Args {
|
||||
/// Library id to sample from.
|
||||
#[arg(long)]
|
||||
library: i32,
|
||||
|
||||
/// Max files to encode. CPU inference is slow (~1-3 s per photo at
|
||||
/// ViT-L/14); start small and grow once GPU is sorted.
|
||||
#[arg(long, default_value_t = 50)]
|
||||
limit: usize,
|
||||
|
||||
/// Natural-language query. Empty triggers an error from Apollo.
|
||||
#[arg(long)]
|
||||
query: String,
|
||||
|
||||
/// How many top results to print.
|
||||
#[arg(long, default_value_t = 10)]
|
||||
top: usize,
|
||||
|
||||
/// Offset into the library's rel_path listing.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
offset: i64,
|
||||
|
||||
/// How many DB rows to scan before giving up on hitting the limit.
|
||||
#[arg(long, default_value_t = 5000)]
|
||||
max_scan: i64,
|
||||
}
|
||||
|
||||
/// Same as `face_watch::read_image_bytes_for_detect` (which is pub(crate)).
|
||||
/// Inlined for the throwaway probe.
|
||||
fn read_image_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||
if file_types::needs_ffmpeg_thumbnail(path)
|
||||
&& let Some(preview) = exif::extract_embedded_jpeg_preview(path)
|
||||
{
|
||||
return Ok(preview);
|
||||
}
|
||||
std::fs::read(path)
|
||||
}
|
||||
|
||||
/// Decode a base64'd LE float32 vector to a `Vec<f32>`.
|
||||
fn decode_f32_vec(b64: &str) -> anyhow::Result<Vec<f32>> {
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(b64.as_bytes())?;
|
||||
if bytes.len() % 4 != 0 {
|
||||
anyhow::bail!("embedding byte length {} not divisible by 4", bytes.len());
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() / 4);
|
||||
for chunk in bytes.chunks_exact(4) {
|
||||
out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Plain dot product. Apollo L2-normalizes both sides, so this is cosine sim.
|
||||
fn dot(a: &[f32], b: &[f32]) -> f32 {
|
||||
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
env_logger::init();
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
let args = Args::parse();
|
||||
if args.query.trim().is_empty() {
|
||||
anyhow::bail!("--query must not be empty");
|
||||
}
|
||||
|
||||
let client = ClipClient::from_env();
|
||||
if !client.is_enabled() {
|
||||
anyhow::bail!(
|
||||
"ClipClient disabled: set APOLLO_CLIP_API_BASE_URL or APOLLO_API_BASE_URL in .env"
|
||||
);
|
||||
}
|
||||
|
||||
match client.health().await {
|
||||
Ok(h) => info!(
|
||||
"clip engine: loaded={} device={} model={} dim={}",
|
||||
h.loaded, h.device, h.model_version, h.embedding_dim
|
||||
),
|
||||
Err(e) => warn!("health probe failed (continuing): {e}"),
|
||||
}
|
||||
|
||||
let mut seed_conn = connect();
|
||||
if let Some(base) = dotenv::var("BASE_PATH").ok().as_deref() {
|
||||
libraries::seed_or_patch_from_env(&mut seed_conn, base);
|
||||
}
|
||||
let libs = libraries::load_all(&mut seed_conn);
|
||||
drop(seed_conn);
|
||||
let lib: Library = libs
|
||||
.into_iter()
|
||||
.find(|l| l.id == args.library)
|
||||
.ok_or_else(|| anyhow::anyhow!("library id {} not found", args.library))?;
|
||||
info!(
|
||||
"probing library #{} ({}) at {}",
|
||||
lib.id, lib.name, lib.root_path
|
||||
);
|
||||
|
||||
let dao: Arc<Mutex<Box<dyn ExifDao>>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
|
||||
let ctx = opentelemetry::Context::new();
|
||||
|
||||
// Encode the query up-front so the long image-encode loop doesn't
|
||||
// race a slow query encode. Fails fast on a misspelled query.
|
||||
let query_resp = client
|
||||
.encode_text(&args.query)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("encode_text: {e}"))?;
|
||||
let query_vec = decode_f32_vec(&query_resp.embedding)?;
|
||||
info!(
|
||||
"query encoded ({}d, {}ms): {:?}",
|
||||
query_resp.embedding_dim, query_resp.duration_ms, args.query
|
||||
);
|
||||
|
||||
// Page through (id, rel_path), filter to images on disk, encode up
|
||||
// to `limit`. Each encoded photo gets scored against the query and
|
||||
// kept in a top-K heap.
|
||||
const PAGE: i64 = 500;
|
||||
let mut offset = args.offset;
|
||||
let mut scanned: i64 = 0;
|
||||
let mut encoded = 0usize;
|
||||
let mut perm_fail = 0usize;
|
||||
let mut transient_fail = 0usize;
|
||||
let root = PathBuf::from(&lib.root_path);
|
||||
let started = Instant::now();
|
||||
// (similarity, rel_path) — we keep all scored results and sort at
|
||||
// the end. With limit≤few-hundred this is trivial.
|
||||
let mut scores: Vec<(f32, String)> = Vec::with_capacity(args.limit);
|
||||
|
||||
'outer: loop {
|
||||
if scanned >= args.max_scan {
|
||||
warn!(
|
||||
"scan cap ({}) reached before hitting limit ({}); bump --max-scan to scan deeper",
|
||||
args.max_scan, args.limit
|
||||
);
|
||||
break;
|
||||
}
|
||||
let rows = {
|
||||
let mut guard = dao.lock().expect("dao lock");
|
||||
guard
|
||||
.list_rel_paths_for_library_page(&ctx, lib.id, PAGE, offset)
|
||||
.map_err(|e| anyhow::anyhow!("list rel_paths: {:?}", e))?
|
||||
};
|
||||
if rows.is_empty() {
|
||||
info!("no more rows after offset {}", offset);
|
||||
break;
|
||||
}
|
||||
offset += rows.len() as i64;
|
||||
scanned += rows.len() as i64;
|
||||
|
||||
for (_id, rel_path) in rows {
|
||||
if encoded >= args.limit {
|
||||
break 'outer;
|
||||
}
|
||||
let abs = root.join(&rel_path);
|
||||
if !file_types::is_image_file(&abs) || !abs.exists() {
|
||||
continue;
|
||||
}
|
||||
let bytes = match read_image_bytes(&abs) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("read {rel_path}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let meta = EncodeImageMeta {
|
||||
content_hash: String::new(),
|
||||
library_id: lib.id,
|
||||
rel_path: rel_path.clone(),
|
||||
};
|
||||
let call_start = Instant::now();
|
||||
match client.encode_image(bytes, meta).await {
|
||||
Ok(resp) => {
|
||||
encoded += 1;
|
||||
let vec = match decode_f32_vec(&resp.embedding) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("decode {rel_path}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if vec.len() != query_vec.len() {
|
||||
warn!(
|
||||
"dim mismatch for {rel_path}: image={} query={}",
|
||||
vec.len(),
|
||||
query_vec.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let sim = dot(&vec, &query_vec);
|
||||
scores.push((sim, rel_path.clone()));
|
||||
if encoded.is_multiple_of(10) {
|
||||
info!(
|
||||
"progress: {} encoded, {:.1}s elapsed",
|
||||
encoded,
|
||||
started.elapsed().as_secs_f32()
|
||||
);
|
||||
}
|
||||
let _ = call_start;
|
||||
}
|
||||
Err(ClipError::Permanent(e)) => {
|
||||
perm_fail += 1;
|
||||
warn!("permanent encode failure for {rel_path}: {e}");
|
||||
}
|
||||
Err(ClipError::Transient(e)) => {
|
||||
transient_fail += 1;
|
||||
warn!("transient encode failure for {rel_path}: {e}");
|
||||
}
|
||||
Err(ClipError::Disabled) => {
|
||||
anyhow::bail!("clip client became disabled mid-run; impossible");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scores.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let elapsed = started.elapsed();
|
||||
println!();
|
||||
println!(
|
||||
"── top {} for query: {:?} ──",
|
||||
args.top.min(scores.len()),
|
||||
args.query
|
||||
);
|
||||
for (i, (sim, path)) in scores.iter().take(args.top).enumerate() {
|
||||
println!("[{:>2}] sim={:.3} {}", i + 1, sim, path);
|
||||
}
|
||||
println!();
|
||||
println!("── summary ─────────────────────────────────────");
|
||||
println!("query : {:?}", args.query);
|
||||
println!("scanned rows : {scanned}");
|
||||
println!("encoded photos : {encoded}");
|
||||
println!("permanent failures : {perm_fail}");
|
||||
println!("transient failures : {transient_fail}");
|
||||
println!("elapsed : {:.1}s", elapsed.as_secs_f32());
|
||||
if encoded > 0 {
|
||||
println!(
|
||||
"throughput : {:.2} photos/s ({:.0}ms/photo avg)",
|
||||
encoded as f32 / elapsed.as_secs_f32().max(0.001),
|
||||
elapsed.as_millis() as f32 / encoded as f32
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
//! Re-embed stored corpora through `LocalLlm`, i.e. the same
|
||||
//! `LLM_BACKEND` dispatch the query side uses. The original import /
|
||||
//! backfill tools always embedded via Ollama, so a deploy running
|
||||
//! `LLM_BACKEND=llamacpp` queries vector spaces the corpora may not live
|
||||
//! in. Three tables share the problem and are all covered here:
|
||||
//!
|
||||
//! - `daily_conversation_summaries` — re-embeds
|
||||
//! `strip_summary_boilerplate(summary)` (what the original job fed the
|
||||
//! embedder); also rewrites `model_version`.
|
||||
//! - `calendar_events` — re-embeds "summary description location" exactly
|
||||
//! as `import_calendar` does; rows without an embedding are skipped (the
|
||||
//! import only embeds under `--generate-embeddings`).
|
||||
//! - `search_history` — re-embeds the raw query text.
|
||||
//! - `entities` (knowledge graph) — re-embeds "name description" exactly as
|
||||
//! `tool_store_entity` does; embedding-less rows are skipped (embedding
|
||||
//! is best-effort at store time).
|
||||
//!
|
||||
//! Source text is untouched — only vectors are rewritten. The old↔new
|
||||
//! cosine report doubles as a diagnostic: ~1.0 means both backends already
|
||||
//! shared a space (re-embedding was a no-op); low values confirm the
|
||||
//! mismatch this tool exists to fix.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_query;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use image_api::ai::{LocalLlm, strip_summary_boilerplate};
|
||||
use image_api::bin_progress;
|
||||
use std::env;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about = "Re-embed stored corpora via the configured LLM_BACKEND", long_about = None)]
|
||||
struct Args {
|
||||
/// Comma-separated tables to process: summaries, calendar, search, entities
|
||||
#[arg(long, default_value = "summaries,calendar,search,entities")]
|
||||
tables: String,
|
||||
|
||||
/// Only process the first N rows per table (smoke test)
|
||||
#[arg(long)]
|
||||
limit: Option<usize>,
|
||||
|
||||
/// Compute embeddings and report old↔new similarity without writing
|
||||
#[arg(long, default_value_t = false)]
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
struct SummaryRow {
|
||||
#[diesel(sql_type = diesel::sql_types::Integer)]
|
||||
id: i32,
|
||||
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||
summary: String,
|
||||
#[diesel(sql_type = diesel::sql_types::Binary)]
|
||||
embedding: Vec<u8>,
|
||||
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||
model_version: String,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
struct CalendarRow {
|
||||
#[diesel(sql_type = diesel::sql_types::Integer)]
|
||||
id: i32,
|
||||
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||
summary: String,
|
||||
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
|
||||
description: Option<String>,
|
||||
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
|
||||
location: Option<String>,
|
||||
#[diesel(sql_type = diesel::sql_types::Binary)]
|
||||
embedding: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
struct SearchRow {
|
||||
#[diesel(sql_type = diesel::sql_types::BigInt)]
|
||||
id: i64,
|
||||
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||
query: String,
|
||||
#[diesel(sql_type = diesel::sql_types::Binary)]
|
||||
embedding: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
struct EntityRow {
|
||||
#[diesel(sql_type = diesel::sql_types::Integer)]
|
||||
id: i32,
|
||||
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||
name: String,
|
||||
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||
description: String,
|
||||
#[diesel(sql_type = diesel::sql_types::Binary)]
|
||||
embedding: Vec<u8>,
|
||||
}
|
||||
|
||||
/// One unit of re-embed work, normalized across tables.
|
||||
struct WorkItem {
|
||||
/// Row key, as i64 so both i32 ids and rowids fit.
|
||||
id: i64,
|
||||
/// Text fed to the embedder — must match what the original writer used.
|
||||
text: String,
|
||||
/// Existing vector bytes, for the old↔new similarity report.
|
||||
old_embedding: Vec<u8>,
|
||||
}
|
||||
|
||||
fn deserialize_vector(bytes: &[u8]) -> Option<Vec<f32>> {
|
||||
if !bytes.len().is_multiple_of(4) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
bytes
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn serialize_vector(vec: &[f32]) -> Vec<u8> {
|
||||
vec.iter().flat_map(|f| f.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
if a.len() != b.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if mag_a == 0.0 || mag_b == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
dot / (mag_a * mag_b)
|
||||
}
|
||||
|
||||
/// Embed `text`, halving it on "input too large" errors until it fits the
|
||||
/// server's physical batch (`--ubatch-size`). Mirrors the silent truncation
|
||||
/// Ollama applied when these corpora were first embedded — llama-server
|
||||
/// returns a 500 instead — except here it's surfaced via the returned flag.
|
||||
/// Returns `(embedding, truncated)`.
|
||||
async fn embed_with_truncation(llm: &LocalLlm, text: &str) -> Result<(Vec<f32>, bool)> {
|
||||
let mut text = text.to_string();
|
||||
let mut truncated = false;
|
||||
loop {
|
||||
match llm.embed_document(&text).await {
|
||||
Ok(emb) => return Ok((emb, truncated)),
|
||||
Err(e)
|
||||
if e.to_string().contains("too large to process") && text.chars().count() > 64 =>
|
||||
{
|
||||
let keep = text.chars().count() / 2;
|
||||
text = text.chars().take(keep).collect();
|
||||
truncated = true;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-embed `items`, writing each new vector via `update`. Returns the
|
||||
/// old↔new cosines for the similarity report.
|
||||
async fn reembed_table(
|
||||
conn: &mut SqliteConnection,
|
||||
llm: &LocalLlm,
|
||||
label: &str,
|
||||
items: Vec<WorkItem>,
|
||||
dry_run: bool,
|
||||
update: impl Fn(&mut SqliteConnection, i64, Vec<u8>) -> Result<()>,
|
||||
) -> Result<Vec<f32>> {
|
||||
println!("\n[{}] re-embedding {} rows...", label, items.len());
|
||||
let pb = bin_progress::determinate(items.len() as u64, format!("re-embedding {}", label));
|
||||
|
||||
let mut sims: Vec<f32> = Vec::with_capacity(items.len());
|
||||
let mut updated = 0usize;
|
||||
let mut failed = 0usize;
|
||||
let mut truncated_count = 0usize;
|
||||
|
||||
for item in &items {
|
||||
let new_emb = match embed_with_truncation(llm, &item.text).await {
|
||||
Ok((e, truncated)) => {
|
||||
if truncated {
|
||||
truncated_count += 1;
|
||||
pb.println(format!(
|
||||
"⚠ {} id={}: input exceeded the embed server's batch size, \
|
||||
truncated before embedding",
|
||||
label, item.id
|
||||
));
|
||||
}
|
||||
e
|
||||
}
|
||||
Err(e) => {
|
||||
pb.inc(1);
|
||||
failed += 1;
|
||||
eprintln!("✗ {} id={}: {}", label, item.id, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// The whole pipeline (DAO checks, stored corpora) assumes
|
||||
// EMBEDDING_DIM dims. A mismatch means the active embed slot is not
|
||||
// serving the configured model — stop rather than corrupt the table.
|
||||
anyhow::ensure!(
|
||||
new_emb.len() == image_api::ai::embedding_dim(),
|
||||
"backend returned {}-dim embedding (expected {}) — '{}' does not \
|
||||
match the configured EMBEDDING_DIM",
|
||||
new_emb.len(),
|
||||
image_api::ai::embedding_dim(),
|
||||
llm.embedding_model_version()
|
||||
);
|
||||
|
||||
if let Some(old_emb) = deserialize_vector(&item.old_embedding) {
|
||||
sims.push(cosine_similarity(&old_emb, &new_emb));
|
||||
}
|
||||
|
||||
if !dry_run {
|
||||
update(conn, item.id, serialize_vector(&new_emb))
|
||||
.with_context(|| format!("updating {} id={}", label, item.id))?;
|
||||
}
|
||||
updated += 1;
|
||||
pb.inc(1);
|
||||
}
|
||||
pb.finish_and_clear();
|
||||
|
||||
println!(
|
||||
"[{}] {} re-embedded ({} truncated), {} failed",
|
||||
label, updated, truncated_count, failed
|
||||
);
|
||||
Ok(sims)
|
||||
}
|
||||
|
||||
fn report_similarity(label: &str, mut sims: Vec<f32>) {
|
||||
if sims.is_empty() {
|
||||
println!("[{}] no old↔new pairs to compare", label);
|
||||
return;
|
||||
}
|
||||
sims.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mean: f32 = sims.iter().sum::<f32>() / sims.len() as f32;
|
||||
let median = sims[sims.len() / 2];
|
||||
println!(
|
||||
"[{}] old↔new cosine over identical text: min={:.3} median={:.3} mean={:.3} max={:.3}",
|
||||
label,
|
||||
sims.first().unwrap(),
|
||||
median,
|
||||
mean,
|
||||
sims.last().unwrap()
|
||||
);
|
||||
if median > 0.98 {
|
||||
println!(
|
||||
"[{}] → old and new backends agree (~same vector space); poor search \
|
||||
results are coming from something else (prefixes, thresholds, corpus).",
|
||||
label
|
||||
);
|
||||
} else if median > 0.9 {
|
||||
println!(
|
||||
"[{}] → same model family but measurably different vectors \
|
||||
(quantization / runtime drift); re-embedding was worthwhile.",
|
||||
label
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"[{}] → vector-space mismatch confirmed — queries were searching a \
|
||||
different space than the corpus. This re-embed should fix it.",
|
||||
label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
env_logger::init();
|
||||
let args = Args::parse();
|
||||
|
||||
let tables: Vec<&str> = args.tables.split(',').map(|t| t.trim()).collect();
|
||||
for t in &tables {
|
||||
anyhow::ensure!(
|
||||
matches!(*t, "summaries" | "calendar" | "search" | "entities"),
|
||||
"unknown table '{}' — expected summaries, calendar, search, entities",
|
||||
t
|
||||
);
|
||||
}
|
||||
|
||||
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "auth.db".to_string());
|
||||
println!("Database: {}", database_url);
|
||||
|
||||
let mut conn = SqliteConnection::establish(&database_url)
|
||||
.with_context(|| format!("connecting to {}", database_url))?;
|
||||
|
||||
let llm = LocalLlm::from_env();
|
||||
let model_version = llm.embedding_model_version();
|
||||
println!("Embedding via '{}'", model_version);
|
||||
if args.dry_run {
|
||||
println!("DRY RUN — no rows will be written");
|
||||
}
|
||||
|
||||
if tables.contains(&"summaries") {
|
||||
let mut rows: Vec<SummaryRow> = sql_query(
|
||||
"SELECT id, summary, embedding, model_version
|
||||
FROM daily_conversation_summaries ORDER BY date",
|
||||
)
|
||||
.load(&mut conn)
|
||||
.context("loading daily summaries")?;
|
||||
if let Some(limit) = args.limit {
|
||||
rows.truncate(limit);
|
||||
}
|
||||
if let Some(first) = rows.first() {
|
||||
println!(
|
||||
"\n[summaries] previous model_version '{}' → '{}'",
|
||||
first.model_version, model_version
|
||||
);
|
||||
}
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| WorkItem {
|
||||
id: r.id as i64,
|
||||
text: strip_summary_boilerplate(&r.summary),
|
||||
old_embedding: r.embedding,
|
||||
})
|
||||
.collect();
|
||||
let mv = model_version.clone();
|
||||
let sims = reembed_table(
|
||||
&mut conn,
|
||||
&llm,
|
||||
"summaries",
|
||||
items,
|
||||
args.dry_run,
|
||||
move |conn, id, emb| {
|
||||
sql_query(
|
||||
"UPDATE daily_conversation_summaries
|
||||
SET embedding = ?1, model_version = ?2 WHERE id = ?3",
|
||||
)
|
||||
.bind::<diesel::sql_types::Binary, _>(emb)
|
||||
.bind::<diesel::sql_types::Text, _>(&mv)
|
||||
.bind::<diesel::sql_types::Integer, _>(id as i32)
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
report_similarity("summaries", sims);
|
||||
}
|
||||
|
||||
if tables.contains(&"calendar") {
|
||||
let mut rows: Vec<CalendarRow> = sql_query(
|
||||
"SELECT id, summary, description, location, embedding
|
||||
FROM calendar_events WHERE embedding IS NOT NULL ORDER BY id",
|
||||
)
|
||||
.load(&mut conn)
|
||||
.context("loading calendar events")?;
|
||||
if let Some(limit) = args.limit {
|
||||
rows.truncate(limit);
|
||||
}
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| WorkItem {
|
||||
id: r.id as i64,
|
||||
// Same text construction as import_calendar.
|
||||
text: format!(
|
||||
"{} {} {}",
|
||||
r.summary,
|
||||
r.description.as_deref().unwrap_or(""),
|
||||
r.location.as_deref().unwrap_or("")
|
||||
),
|
||||
old_embedding: r.embedding,
|
||||
})
|
||||
.collect();
|
||||
let sims = reembed_table(
|
||||
&mut conn,
|
||||
&llm,
|
||||
"calendar",
|
||||
items,
|
||||
args.dry_run,
|
||||
|conn, id, emb| {
|
||||
sql_query("UPDATE calendar_events SET embedding = ?1 WHERE id = ?2")
|
||||
.bind::<diesel::sql_types::Binary, _>(emb)
|
||||
.bind::<diesel::sql_types::Integer, _>(id as i32)
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
report_similarity("calendar", sims);
|
||||
}
|
||||
|
||||
if tables.contains(&"search") {
|
||||
let mut rows: Vec<SearchRow> = sql_query(
|
||||
"SELECT rowid AS id, query, embedding
|
||||
FROM search_history ORDER BY rowid",
|
||||
)
|
||||
.load(&mut conn)
|
||||
.context("loading search history")?;
|
||||
if let Some(limit) = args.limit {
|
||||
rows.truncate(limit);
|
||||
}
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| WorkItem {
|
||||
id: r.id,
|
||||
text: r.query,
|
||||
old_embedding: r.embedding,
|
||||
})
|
||||
.collect();
|
||||
let sims = reembed_table(
|
||||
&mut conn,
|
||||
&llm,
|
||||
"search",
|
||||
items,
|
||||
args.dry_run,
|
||||
|conn, id, emb| {
|
||||
sql_query("UPDATE search_history SET embedding = ?1 WHERE rowid = ?2")
|
||||
.bind::<diesel::sql_types::Binary, _>(emb)
|
||||
.bind::<diesel::sql_types::BigInt, _>(id)
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
report_similarity("search", sims);
|
||||
}
|
||||
|
||||
if tables.contains(&"entities") {
|
||||
let mut rows: Vec<EntityRow> = sql_query(
|
||||
"SELECT id, name, description, embedding
|
||||
FROM entities WHERE embedding IS NOT NULL ORDER BY id",
|
||||
)
|
||||
.load(&mut conn)
|
||||
.context("loading knowledge entities")?;
|
||||
if let Some(limit) = args.limit {
|
||||
rows.truncate(limit);
|
||||
}
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| WorkItem {
|
||||
id: r.id as i64,
|
||||
// Same text construction as tool_store_entity.
|
||||
text: format!("{} {}", r.name, r.description),
|
||||
old_embedding: r.embedding,
|
||||
})
|
||||
.collect();
|
||||
let sims = reembed_table(
|
||||
&mut conn,
|
||||
&llm,
|
||||
"entities",
|
||||
items,
|
||||
args.dry_run,
|
||||
|conn, id, emb| {
|
||||
sql_query("UPDATE entities SET embedding = ?1 WHERE id = ?2")
|
||||
.bind::<diesel::sql_types::Binary, _>(emb)
|
||||
.bind::<diesel::sql_types::Integer, _>(id as i32)
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
report_similarity("entities", sims);
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n{}",
|
||||
if args.dry_run {
|
||||
"Dry run complete"
|
||||
} else {
|
||||
"Done"
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
//! `/photos/search?q=<text>` — CLIP semantic photo search.
|
||||
//!
|
||||
//! The route lives outside `files.rs` to keep that 1500+ line module
|
||||
//! focused on EXIF / tag listing. The flow is:
|
||||
//!
|
||||
//! 1. Parse query params (`q`, `limit`, `threshold`, optional `library`).
|
||||
//! 2. Call Apollo's `/api/internal/clip/encode_text` to get the query
|
||||
//! vector (L2-normalized 768-d f32 for ViT-L/14).
|
||||
//! 3. Load every `(content_hash, clip_embedding)` for the scope from
|
||||
//! `image_exif` via `ExifDao::list_clip_index`. ~28–43 MB for a 14k
|
||||
//! library at ViT-L/14; loaded fresh per request — fast enough for
|
||||
//! v1, optimize via an AppState cache later if needed.
|
||||
//! 4. Dot product (= cosine since both sides are L2-normalized), filter
|
||||
//! above `threshold`, top-K by score.
|
||||
//! 5. Resolve each surviving hash back to a `(library_id, rel_path)` so
|
||||
//! the frontend can render the photo / hand off to the carousel.
|
||||
//!
|
||||
//! Response shape is intentionally minimal — paths + score — so the
|
||||
//! frontend can reuse existing PhotoGrid rendering by joining against
|
||||
//! `/api/photos/match` (or calling `/image/metadata` lazily). Don't
|
||||
//! bake camera/EXIF metadata into this route; it would force a fan-out
|
||||
//! per result and balloon the response.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::ai::clip_client::ClipError;
|
||||
use crate::database::ExifDao;
|
||||
use actix_web::{HttpResponse, Result as ActixResult, web};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
/// Natural-language query. Required; empty triggers 400.
|
||||
pub q: String,
|
||||
/// Max results to return in this page. Capped to 200 server-side.
|
||||
/// Defaults to 20. Pair with `offset` for pagination.
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: usize,
|
||||
/// Zero-based offset into the sorted-and-filtered result set. The
|
||||
/// scoring loop still runs over the full embedding matrix on every
|
||||
/// page (cheap at personal-library scale — sub-100ms — and avoids
|
||||
/// stateful pagination cursors). Defaults to 0.
|
||||
#[serde(default)]
|
||||
pub offset: usize,
|
||||
/// Cosine-similarity floor below which results are dropped.
|
||||
/// 0.20 is the rough "this is plausibly relevant" line for OpenAI
|
||||
/// CLIP; tunable per call when sweeping. Defaults to 0.20.
|
||||
#[serde(default = "default_threshold")]
|
||||
pub threshold: f32,
|
||||
/// Optional single-library scope. Legacy param — new clients pass
|
||||
/// `library_ids` instead so multi-select scopes (Apollo's HUD library
|
||||
/// chips, FileViewer-React's library picker) actually filter. Kept
|
||||
/// for back-compat; `library_ids` wins when both are supplied.
|
||||
pub library: Option<i32>,
|
||||
/// Optional multi-library scope, comma-separated id list
|
||||
/// (`?library_ids=1,3`). Empty / omitted = every enabled library
|
||||
/// (the historical default). Apollo and FileViewer-React both send
|
||||
/// this when 2+ libraries are selected; the single-library case
|
||||
/// works through either param interchangeably.
|
||||
pub library_ids: Option<String>,
|
||||
/// Optional model-version filter. Defaults to the live engine's
|
||||
/// version (queried lazily). Forces a strict join so mid-flight
|
||||
/// model swaps can't mix geometries in a single response.
|
||||
#[serde(default)]
|
||||
pub model_version: Option<String>,
|
||||
}
|
||||
|
||||
fn default_limit() -> usize {
|
||||
20
|
||||
}
|
||||
|
||||
fn default_threshold() -> f32 {
|
||||
0.20
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchHit {
|
||||
pub library_id: i32,
|
||||
pub rel_path: String,
|
||||
pub content_hash: String,
|
||||
/// Cosine similarity in [-1, 1]. In practice OpenAI CLIP returns
|
||||
/// 0.10–0.40 for the typical photo library.
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchResponse {
|
||||
pub query: String,
|
||||
pub model_version: String,
|
||||
pub threshold: f32,
|
||||
/// Total embeddings scored (= every photo in scope with a stored
|
||||
/// embedding). Same value across pages of the same query.
|
||||
pub considered: usize,
|
||||
/// Count of results above threshold, before pagination. Lets the
|
||||
/// client decide whether a "Load more" button is meaningful and
|
||||
/// stop fetching when ``offset + results.len() >= total_matching``.
|
||||
pub total_matching: usize,
|
||||
pub offset: usize,
|
||||
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.
|
||||
fn decode_embedding(bytes: &[u8]) -> Option<Vec<f32>> {
|
||||
if bytes.is_empty() || !bytes.len().is_multiple_of(4) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() / 4);
|
||||
for chunk in bytes.chunks_exact(4) {
|
||||
out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn dot(a: &[f32], b: &[f32]) -> f32 {
|
||||
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
/// Failure modes of [`score_photos`]. Carries enough to let each caller pick
|
||||
/// an appropriate HTTP status (the CLIP service being down is a 502, a
|
||||
/// disabled feature is a 503, a rejected query is a 400, a DB failure 500).
|
||||
pub enum ScoreError {
|
||||
/// CLIP search isn't configured at all (no Apollo endpoint).
|
||||
Disabled,
|
||||
/// The query was rejected by the encoder (client error).
|
||||
Rejected(String),
|
||||
/// The CLIP service is transiently unavailable (upstream error).
|
||||
Unavailable(String),
|
||||
/// The encoder returned an embedding we couldn't decode.
|
||||
MalformedEmbedding,
|
||||
/// A database / index load failure.
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Result of scoring the whole library against a query embedding: the
|
||||
/// resolved model version, how many embeddings were considered, and every
|
||||
/// `(score, content_hash)` above threshold, sorted by descending score.
|
||||
/// Pagination and path resolution are the caller's job (see [`resolve_hits`])
|
||||
/// so this core can be reused for both the plain search endpoint and the
|
||||
/// unified endpoint (which filters by hash before paginating).
|
||||
pub struct ScoredPhotos {
|
||||
pub model_version: String,
|
||||
pub considered: usize,
|
||||
/// `(cosine_score, content_hash)` pairs, descending by score.
|
||||
pub hits: Vec<(f32, String)>,
|
||||
}
|
||||
|
||||
/// Encode `q_text` via CLIP and score it against every stored embedding in
|
||||
/// the given library scope. Returns all matches above `threshold`, sorted by
|
||||
/// descending similarity. Pure of HTTP concerns so it's shared by
|
||||
/// `search_photos` and the unified search endpoint.
|
||||
pub async fn score_photos(
|
||||
state: &AppState,
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
q_text: &str,
|
||||
library_ids: &[i32],
|
||||
threshold: f32,
|
||||
model_version: Option<&str>,
|
||||
) -> Result<ScoredPhotos, ScoreError> {
|
||||
if !state.clip_client.is_enabled() {
|
||||
return Err(ScoreError::Disabled);
|
||||
}
|
||||
|
||||
// 1. Encode the query text. Fast — Apollo's text encoder is ~50ms on CPU.
|
||||
let query_resp = match state.clip_client.encode_text(q_text).await {
|
||||
Ok(r) => r,
|
||||
Err(ClipError::Permanent(e)) => return Err(ScoreError::Rejected(e.to_string())),
|
||||
Err(ClipError::Transient(e)) => return Err(ScoreError::Unavailable(e.to_string())),
|
||||
Err(ClipError::Disabled) => return Err(ScoreError::Disabled),
|
||||
};
|
||||
// decode_embedding works on raw bytes; the wire format is b64.
|
||||
let query_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(query_resp.embedding.as_bytes())
|
||||
.unwrap_or_default();
|
||||
let query_vec = decode_embedding(&query_bytes).ok_or(ScoreError::MalformedEmbedding)?;
|
||||
|
||||
// 2. Pull the (hash, embedding) matrix under the dao lock, release
|
||||
// before scoring. The caller-supplied `model_version` (or the live
|
||||
// engine's) forces a strict join so a mid-flight model swap can't mix
|
||||
// geometries.
|
||||
let ctx = opentelemetry::Context::current();
|
||||
let rows: Vec<(String, Vec<u8>)> = {
|
||||
let mut dao = exif_dao.lock().expect("exif dao");
|
||||
dao.list_clip_index(
|
||||
&ctx,
|
||||
library_ids,
|
||||
model_version.or(Some(&query_resp.model_version)),
|
||||
)
|
||||
.map_err(|e| {
|
||||
log::warn!("clip_search: list_clip_index failed: {:?}", e);
|
||||
ScoreError::Internal("failed to load search index".into())
|
||||
})?
|
||||
};
|
||||
let considered = rows.len();
|
||||
|
||||
// 3. Score. Keep all matches and sort at the end (~microseconds at 14k).
|
||||
let mut hits: Vec<(f32, String)> = Vec::with_capacity(considered);
|
||||
for (hash, blob) in rows {
|
||||
let Some(emb) = decode_embedding(&blob) else {
|
||||
continue;
|
||||
};
|
||||
if emb.len() != query_vec.len() {
|
||||
continue;
|
||||
}
|
||||
let sim = dot(&emb, &query_vec);
|
||||
if sim < threshold {
|
||||
continue;
|
||||
}
|
||||
hits.push((sim, hash));
|
||||
}
|
||||
hits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
Ok(ScoredPhotos {
|
||||
model_version: query_resp.model_version,
|
||||
considered,
|
||||
hits,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a page of `(score, content_hash)` pairs back to [`SearchHit`]s
|
||||
/// (each carrying `library_id` + `rel_path`). Hashes that no longer resolve
|
||||
/// to a row are skipped. Shared by both endpoints.
|
||||
pub fn resolve_hits(
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
scored: &[(f32, String)],
|
||||
) -> Vec<SearchHit> {
|
||||
if scored.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let ctx = opentelemetry::Context::current();
|
||||
let hashes: Vec<String> = scored.iter().map(|(_, h)| h.clone()).collect();
|
||||
let mut dao = exif_dao.lock().expect("exif dao");
|
||||
let path_map = dao
|
||||
.get_rel_paths_for_hashes(&ctx, &hashes)
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("clip_search: get_rel_paths_for_hashes failed: {:?}", e);
|
||||
std::collections::HashMap::new()
|
||||
});
|
||||
|
||||
let mut results = Vec::with_capacity(scored.len());
|
||||
for (score, hash) in scored {
|
||||
let row = match dao.find_by_content_hash(&ctx, hash) {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
log::warn!("clip_search: find_by_content_hash failed for {hash}: {e:?}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Prefer get_rel_paths_for_hashes's first entry (shares image_exif's
|
||||
// natural order), falling back to the ImageExif row.
|
||||
let rel_path = path_map
|
||||
.get(hash)
|
||||
.and_then(|paths| paths.first().cloned())
|
||||
.unwrap_or(row.file_path);
|
||||
results.push(SearchHit {
|
||||
library_id: row.library_id,
|
||||
rel_path,
|
||||
content_hash: hash.clone(),
|
||||
score: *score,
|
||||
});
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// Parse the `library_ids` (multi) / `library` (single) scope params into a
|
||||
/// deduped id list. Empty = "every enabled library". Shared so the unified
|
||||
/// endpoint scopes CLIP identically.
|
||||
pub fn parse_library_scope(
|
||||
library_ids: Option<&str>,
|
||||
library: Option<i32>,
|
||||
) -> Result<Vec<i32>, String> {
|
||||
if let Some(raw) = library_ids {
|
||||
let mut out: Vec<i32> = Vec::new();
|
||||
for piece in raw.split(',') {
|
||||
let trimmed = piece.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match trimmed.parse::<i32>() {
|
||||
Ok(id) => {
|
||||
if !out.contains(&id) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
Err(_) => return Err(format!("invalid library_ids entry: {trimmed:?}")),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
} else if let Some(id) = library {
|
||||
Ok(vec![id])
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_photos(
|
||||
state: web::Data<AppState>,
|
||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||
query: web::Query<SearchQuery>,
|
||||
) -> ActixResult<HttpResponse> {
|
||||
let q_text = query.q.trim().to_string();
|
||||
if q_text.is_empty() {
|
||||
return Ok(HttpResponse::BadRequest().json(SearchError {
|
||||
error: "query parameter `q` is required".into(),
|
||||
}));
|
||||
}
|
||||
|
||||
let limit = query.limit.clamp(1, 200);
|
||||
let offset = query.offset;
|
||||
let threshold = query.threshold.clamp(-1.0, 1.0);
|
||||
|
||||
let library_ids = match parse_library_scope(query.library_ids.as_deref(), query.library) {
|
||||
Ok(ids) => ids,
|
||||
Err(msg) => return Ok(HttpResponse::BadRequest().json(SearchError { error: msg })),
|
||||
};
|
||||
|
||||
let scored = match score_photos(
|
||||
&state,
|
||||
&exif_dao,
|
||||
&q_text,
|
||||
&library_ids,
|
||||
threshold,
|
||||
query.model_version.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(e) => return Ok(score_error_response(e)),
|
||||
};
|
||||
|
||||
let total_matching = scored.hits.len();
|
||||
// Pagination — slice the sorted list at `[offset, offset+limit)`. Offsets
|
||||
// past the end produce empty pages so "load more" stops naturally.
|
||||
let page: Vec<(f32, String)> = if offset >= total_matching {
|
||||
Vec::new()
|
||||
} else {
|
||||
let end = (offset + limit).min(total_matching);
|
||||
scored.hits[offset..end].to_vec()
|
||||
};
|
||||
let results = resolve_hits(&exif_dao, &page);
|
||||
|
||||
Ok(HttpResponse::Ok().json(SearchResponse {
|
||||
query: q_text,
|
||||
model_version: scored.model_version,
|
||||
threshold,
|
||||
considered: scored.considered,
|
||||
total_matching,
|
||||
offset,
|
||||
results,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Map a [`ScoreError`] to the HTTP response `search_photos` historically
|
||||
/// returned for each failure mode. Reused by the unified endpoint.
|
||||
pub fn score_error_response(e: ScoreError) -> HttpResponse {
|
||||
match e {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
//! CLIP-encoding pass for the file watcher.
|
||||
//!
|
||||
//! `process_clip_backlog` in `backfill.rs` calls [`run_clip_encoding_pass`]
|
||||
//! with the page of candidates returned by
|
||||
//! `ExifDao::list_clip_unencoded_candidates`. We walk those, fan out K
|
||||
//! parallel encode calls to Apollo, and persist the resulting embeddings
|
||||
//! into `image_exif.clip_embedding` / `clip_model_version`.
|
||||
//!
|
||||
//! Unlike the face pipeline, CLIP has no marker rows — a permanent
|
||||
//! failure (un-decodable bytes) leaves the row's `clip_embedding` NULL
|
||||
//! and the drain will retry on the next tick. For personal-library
|
||||
//! scale this is fine; the per-tick cap bounds the wasted work, and
|
||||
//! `file_types::is_image_file` filters out videos / non-media client-
|
||||
//! side so most permanent failures are decoded-but-corrupt files (rare).
|
||||
//!
|
||||
//! The watcher thread isn't in any pre-existing async context, so we
|
||||
//! build a short-lived tokio runtime per pass and `block_on` the join
|
||||
//! of K encode futures. Concurrency knob: `CLIP_ENCODE_CONCURRENCY`
|
||||
//! (default 4 — lower than faces because Apollo's CLIP path doesn't
|
||||
//! release the GIL between preprocess and forward as cleanly).
|
||||
|
||||
use crate::ai::clip_client::{ClipClient, ClipError, EncodeImageMeta};
|
||||
use crate::database::ExifDao;
|
||||
use crate::exif;
|
||||
use crate::file_types;
|
||||
use crate::libraries::Library;
|
||||
use crate::memories::PathExcluder;
|
||||
use log::{debug, info, warn};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// One file the watcher would like to CLIP-encode. Built from the DAO
|
||||
/// `list_clip_unencoded_candidates` result — needs the `content_hash`
|
||||
/// for traceability in Apollo's log lines, even though the embedding
|
||||
/// itself is keyed on `(library_id, rel_path)` for the back-write.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClipCandidate {
|
||||
pub rel_path: String,
|
||||
pub content_hash: String,
|
||||
}
|
||||
|
||||
/// Synchronous entry point. Returns once every candidate has been
|
||||
/// processed (or definitively skipped). No-op when the client is
|
||||
/// disabled so the caller can call unconditionally.
|
||||
pub fn run_clip_encoding_pass(
|
||||
library: &Library,
|
||||
excluded_dirs: &[String],
|
||||
clip_client: &ClipClient,
|
||||
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
candidates: Vec<ClipCandidate>,
|
||||
) {
|
||||
if !clip_client.is_enabled() {
|
||||
return;
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let base = Path::new(&library.root_path);
|
||||
let filtered = filter_excluded(base, excluded_dirs, candidates, Some(&library.name));
|
||||
if filtered.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let concurrency: usize = std::env::var("CLIP_ENCODE_CONCURRENCY")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.filter(|n: &usize| *n > 0)
|
||||
.unwrap_or(4);
|
||||
|
||||
info!(
|
||||
"clip_watch: encoding {} candidate(s) for library '{}' (concurrency {})",
|
||||
filtered.len(),
|
||||
library.name,
|
||||
concurrency
|
||||
);
|
||||
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
warn!("clip_watch: failed to build tokio runtime: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let library_id = library.id;
|
||||
let library_root = library.root_path.clone();
|
||||
rt.block_on(async move {
|
||||
let sem = Arc::new(Semaphore::new(concurrency));
|
||||
let mut handles = Vec::with_capacity(filtered.len());
|
||||
for cand in filtered {
|
||||
let permit_sem = sem.clone();
|
||||
let clip_client = clip_client.clone();
|
||||
let exif_dao = exif_dao.clone();
|
||||
let library_root = library_root.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = permit_sem.acquire().await.expect("clip semaphore");
|
||||
process_one(library_id, &library_root, cand, &clip_client, exif_dao).await;
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn process_one(
|
||||
library_id: i32,
|
||||
library_root: &str,
|
||||
cand: ClipCandidate,
|
||||
clip_client: &ClipClient,
|
||||
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
) {
|
||||
let abs = Path::new(library_root).join(&cand.rel_path);
|
||||
let bytes = match read_image_bytes_for_encode(&abs) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
// Same rationale as face_watch: don't mark — the file may
|
||||
// have been moved/renamed mid-scan; let the next pass retry.
|
||||
warn!(
|
||||
"clip_watch: read failed for {} (lib {}): {}",
|
||||
cand.rel_path, library_id, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let meta = EncodeImageMeta {
|
||||
content_hash: cand.content_hash.clone(),
|
||||
library_id,
|
||||
rel_path: cand.rel_path.clone(),
|
||||
};
|
||||
let ctx = opentelemetry::Context::current();
|
||||
|
||||
match clip_client.encode_image(bytes, meta).await {
|
||||
Ok(resp) => {
|
||||
let emb_bytes = match resp.decode_embedding() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("clip_watch: bad embedding for {}: {:?}", cand.rel_path, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut dao = exif_dao.lock().expect("exif dao");
|
||||
if let Err(e) = dao.backfill_clip_embedding(
|
||||
&ctx,
|
||||
library_id,
|
||||
&cand.rel_path,
|
||||
&emb_bytes,
|
||||
&resp.model_version,
|
||||
) {
|
||||
warn!(
|
||||
"clip_watch: backfill_clip_embedding failed for {}: {:?}",
|
||||
cand.rel_path, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
debug!(
|
||||
"clip_watch: {} → dim={} ({}ms, {})",
|
||||
cand.rel_path, resp.embedding_dim, resp.duration_ms, resp.model_version
|
||||
);
|
||||
}
|
||||
Err(ClipError::Permanent(e)) => {
|
||||
// No marker — the row sits with NULL embedding and the drain
|
||||
// retries next pass. For personal-library scale the cost of
|
||||
// re-attempting permanently-broken files is bounded by the
|
||||
// per-tick cap. If this becomes a recurring noise source,
|
||||
// add a `clip_status` column with `failed` semantics like
|
||||
// face_detections has.
|
||||
warn!(
|
||||
"clip_watch: permanent failure on {} (will retry next pass): {}",
|
||||
cand.rel_path, e
|
||||
);
|
||||
}
|
||||
Err(ClipError::Transient(e)) => {
|
||||
debug!(
|
||||
"clip_watch: transient on {}: {} (will retry next pass)",
|
||||
cand.rel_path, e
|
||||
);
|
||||
}
|
||||
Err(ClipError::Disabled) => {
|
||||
// Defensive — the entry-point already checked is_enabled().
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop candidates whose paths land in an excluded dir or whose
|
||||
/// extension isn't an image. Mirrors `face_watch::filter_excluded` so
|
||||
/// the two backlogs stay shape-consistent. Library name is passed
|
||||
/// purely for the log line that surfaces an exclusion hit.
|
||||
pub fn filter_excluded(
|
||||
base: &Path,
|
||||
excluded_dirs: &[String],
|
||||
candidates: Vec<ClipCandidate>,
|
||||
library_name: Option<&str>,
|
||||
) -> Vec<ClipCandidate> {
|
||||
let excluder = if excluded_dirs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathExcluder::new(base, excluded_dirs))
|
||||
};
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
let abs = base.join(&c.rel_path);
|
||||
if !file_types::is_image_file(&abs) {
|
||||
debug!(
|
||||
"clip_watch: skipping non-image '{}' (lib {})",
|
||||
c.rel_path,
|
||||
library_name.unwrap_or("<unknown>")
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if let Some(ex) = excluder.as_ref()
|
||||
&& ex.is_excluded(&abs)
|
||||
{
|
||||
debug!(
|
||||
"clip_watch: skipping excluded '{}' (lib {})",
|
||||
c.rel_path,
|
||||
library_name.unwrap_or("<unknown>")
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read image bytes for CLIP encoding. Same logic as
|
||||
/// `face_watch::read_image_bytes_for_detect` — RAW / HEIC files don't
|
||||
/// decode in Apollo's PIL pipeline, so we pull the embedded JPEG
|
||||
/// preview the thumbnail pipeline already extracts. Plain JPEG / PNG /
|
||||
/// WebP go through a direct read.
|
||||
pub fn read_image_bytes_for_encode(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||
if file_types::needs_ffmpeg_thumbnail(path)
|
||||
&& let Some(preview) = exif::extract_embedded_jpeg_preview(path)
|
||||
{
|
||||
return Ok(preview);
|
||||
}
|
||||
std::fs::read(path)
|
||||
}
|
||||
+6
-27
@@ -50,32 +50,14 @@ pub fn thumbnail_path(thumbs_dir: &Path, hash: &str) -> PathBuf {
|
||||
thumbs_dir.join(shard).join(format!("{}.jpg", hash))
|
||||
}
|
||||
|
||||
/// Hash-keyed large-preview path: `<thumbs_dir>/_large/<hash[..2]>/<hash>.jpg`.
|
||||
/// Kept under the same root as 200px thumbs so deployments don't need a
|
||||
/// second env var, but namespaced under `_large/` so the existing 200px
|
||||
/// shards don't collide with the larger derivative.
|
||||
pub fn large_preview_path(thumbs_dir: &Path, hash: &str) -> PathBuf {
|
||||
let shard = shard_prefix(hash);
|
||||
thumbs_dir
|
||||
.join("_large")
|
||||
.join(shard)
|
||||
.join(format!("{}.jpg", hash))
|
||||
}
|
||||
|
||||
/// Hash-keyed xlarge-preview path: `<thumbs_dir>/_xlarge/<hash[..2]>/<hash>.jpg`.
|
||||
pub fn xlarge_preview_path(thumbs_dir: &Path, hash: &str) -> PathBuf {
|
||||
let shard = shard_prefix(hash);
|
||||
thumbs_dir
|
||||
.join("_xlarge")
|
||||
.join(shard)
|
||||
.join(format!("{}.jpg", hash))
|
||||
}
|
||||
|
||||
/// Hash-keyed HLS output directory: `<video_dir>/<hash[..2]>/<hash>/`.
|
||||
/// The playlist lives at `playlist.m3u8` inside this directory and its
|
||||
/// segments are co-located so HLS relative references Just Work. See
|
||||
/// [`crate::video::hls_paths`] for the filename constants and the
|
||||
/// per-file helpers built on this dir.
|
||||
/// segments are co-located so HLS relative references Just Work.
|
||||
///
|
||||
/// Allow-dead until Branch B/C rewires the HLS pipeline to use it; the
|
||||
/// helper lives here today so Branch A's path layout decisions stay
|
||||
/// adjacent to thumbnail/legacy ones.
|
||||
#[allow(dead_code)]
|
||||
pub fn hls_dir(video_dir: &Path, hash: &str) -> PathBuf {
|
||||
let shard = shard_prefix(hash);
|
||||
video_dir.join(shard).join(hash)
|
||||
@@ -141,9 +123,6 @@ mod tests {
|
||||
let p = thumbnail_path(thumbs, "abcdef0123");
|
||||
assert_eq!(p, PathBuf::from("/tmp/thumbs/ab/abcdef0123.jpg"));
|
||||
|
||||
let l = large_preview_path(thumbs, "abcdef0123");
|
||||
assert_eq!(l, PathBuf::from("/tmp/thumbs/_large/ab/abcdef0123.jpg"));
|
||||
|
||||
let video = Path::new("/tmp/video");
|
||||
let d = hls_dir(video, "1234deadbeef");
|
||||
assert_eq!(d, PathBuf::from("/tmp/video/12/1234deadbeef"));
|
||||
|
||||
@@ -194,8 +194,6 @@ pub enum MediaType {
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PhotoSize {
|
||||
Full,
|
||||
XLarge,
|
||||
Large,
|
||||
Thumb,
|
||||
}
|
||||
|
||||
|
||||
@@ -222,12 +222,11 @@ impl CalendarEventDao for SqliteCalendarEventDao {
|
||||
|
||||
// Validate embedding dimensions if provided
|
||||
if let Some(ref emb) = event.embedding
|
||||
&& emb.len() != crate::ai::embedding_dim()
|
||||
&& emb.len() != 768
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid embedding dimensions: {} (expected {})",
|
||||
emb.len(),
|
||||
crate::ai::embedding_dim()
|
||||
"Invalid embedding dimensions: {} (expected 768)",
|
||||
emb.len()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -275,7 +274,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
|
||||
source_file: event.source_file,
|
||||
})
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn store_events_batch(
|
||||
@@ -294,7 +293,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
|
||||
for event in events {
|
||||
// Validate embedding if provided
|
||||
if let Some(ref emb) = event.embedding
|
||||
&& emb.len() != crate::ai::embedding_dim()
|
||||
&& emb.len() != 768
|
||||
{
|
||||
log::warn!(
|
||||
"Skipping event with invalid embedding dimensions: {}",
|
||||
@@ -349,7 +348,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
|
||||
|
||||
Ok(inserted)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn find_events_in_range(
|
||||
@@ -374,7 +373,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
|
||||
.map(|rows| rows.into_iter().map(|r| r.to_calendar_event()).collect())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {:?}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn find_similar_events(
|
||||
@@ -386,11 +385,10 @@ impl CalendarEventDao for SqliteCalendarEventDao {
|
||||
trace_db_call(context, "query", "find_similar_events", |_span| {
|
||||
let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao");
|
||||
|
||||
if query_embedding.len() != crate::ai::embedding_dim() {
|
||||
if query_embedding.len() != 768 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid query embedding dimensions: {} (expected {})",
|
||||
query_embedding.len(),
|
||||
crate::ai::embedding_dim()
|
||||
"Invalid query embedding dimensions: {} (expected 768)",
|
||||
query_embedding.len()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -431,7 +429,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
|
||||
|
||||
Ok(scored_events.into_iter().take(limit).map(|(_, event)| event).collect())
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn find_relevant_events_hybrid(
|
||||
@@ -463,11 +461,10 @@ impl CalendarEventDao for SqliteCalendarEventDao {
|
||||
|
||||
// Step 2: If query embedding provided, rank by semantic similarity
|
||||
if let Some(query_emb) = query_embedding {
|
||||
if query_emb.len() != crate::ai::embedding_dim() {
|
||||
if query_emb.len() != 768 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid query embedding dimensions: {} (expected {})",
|
||||
query_emb.len(),
|
||||
crate::ai::embedding_dim()
|
||||
"Invalid query embedding dimensions: {} (expected 768)",
|
||||
query_emb.len()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -503,7 +500,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
|
||||
Ok(events_in_range.into_iter().take(limit).map(|r| r.to_calendar_event()).collect())
|
||||
}
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn event_exists(
|
||||
@@ -531,7 +528,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
|
||||
|
||||
Ok(result.count > 0)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_event_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> {
|
||||
@@ -554,6 +551,6 @@ impl CalendarEventDao for SqliteCalendarEventDao {
|
||||
|
||||
Ok(result.count)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +150,10 @@ impl DailySummaryDao for SqliteDailySummaryDao {
|
||||
.expect("Unable to get DailySummaryDao");
|
||||
|
||||
// Validate embedding dimensions
|
||||
if summary.embedding.len() != crate::ai::embedding_dim() {
|
||||
if summary.embedding.len() != 768 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid embedding dimensions: {} (expected {})",
|
||||
summary.embedding.len(),
|
||||
crate::ai::embedding_dim()
|
||||
"Invalid embedding dimensions: {} (expected 768)",
|
||||
summary.embedding.len()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -191,7 +190,7 @@ impl DailySummaryDao for SqliteDailySummaryDao {
|
||||
model_version: summary.model_version,
|
||||
})
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn find_similar_summaries(
|
||||
@@ -203,11 +202,10 @@ impl DailySummaryDao for SqliteDailySummaryDao {
|
||||
trace_db_call(context, "query", "find_similar_summaries", |_span| {
|
||||
let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao");
|
||||
|
||||
if query_embedding.len() != crate::ai::embedding_dim() {
|
||||
if query_embedding.len() != 768 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid query embedding dimensions: {} (expected {})",
|
||||
query_embedding.len(),
|
||||
crate::ai::embedding_dim()
|
||||
"Invalid query embedding dimensions: {} (expected 768)",
|
||||
query_embedding.len()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -288,7 +286,7 @@ impl DailySummaryDao for SqliteDailySummaryDao {
|
||||
|
||||
Ok(top_results)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn find_similar_summaries_with_time_weight(
|
||||
@@ -301,11 +299,10 @@ impl DailySummaryDao for SqliteDailySummaryDao {
|
||||
trace_db_call(context, "query", "find_similar_summaries_with_time_weight", |_span| {
|
||||
let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao");
|
||||
|
||||
if query_embedding.len() != crate::ai::embedding_dim() {
|
||||
if query_embedding.len() != 768 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid query embedding dimensions: {} (expected {})",
|
||||
query_embedding.len(),
|
||||
crate::ai::embedding_dim()
|
||||
"Invalid query embedding dimensions: {} (expected 768)",
|
||||
query_embedding.len()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -411,7 +408,7 @@ impl DailySummaryDao for SqliteDailySummaryDao {
|
||||
|
||||
Ok(top_results)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn summary_exists(
|
||||
@@ -438,7 +435,7 @@ impl DailySummaryDao for SqliteDailySummaryDao {
|
||||
|
||||
Ok(count > 0)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_summary_count(
|
||||
@@ -460,7 +457,7 @@ impl DailySummaryDao for SqliteDailySummaryDao {
|
||||
.map(|r| r.count)
|
||||
.map_err(|e| anyhow::anyhow!("Count query error: {:?}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn has_any_summaries(&mut self, context: &opentelemetry::Context) -> Result<bool, DbError> {
|
||||
@@ -484,7 +481,7 @@ impl DailySummaryDao for SqliteDailySummaryDao {
|
||||
|
||||
Ok(!rows.is_empty())
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,681 +0,0 @@
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use std::ops::DerefMut;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::database::models::{
|
||||
InsertInsightGenerationJob, InsightGenerationJob, InsightGenerationType, InsightJobStatus,
|
||||
};
|
||||
use crate::database::schema;
|
||||
use crate::database::{DbError, DbErrorKind, connect};
|
||||
use crate::otel::trace_db_call;
|
||||
|
||||
/// Tracks async insight generation jobs. Each call to `create_job` inserts
|
||||
/// a new row; the application layer prevents concurrent running jobs by
|
||||
/// cancelling the old one before creating a new one.
|
||||
pub trait InsightGenerationJobDao: Sync + Send {
|
||||
/// Insert a new running job. Always creates a new row (no upsert).
|
||||
/// Cleans up terminal-state rows for the same key first.
|
||||
fn create_job(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
file_path: &str,
|
||||
generation_type: InsightGenerationType,
|
||||
) -> Result<i32, DbError>;
|
||||
|
||||
/// Mark a job as completed with the resulting insight id. Only updates
|
||||
/// if the job is still in "running" status (prevents overwriting a
|
||||
/// cancelled job with a late-completing task).
|
||||
fn complete_job(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
job_id: i32,
|
||||
insight_id: i32,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
/// Mark a job as failed with an error message. Only updates if the job
|
||||
/// is still in "running" status.
|
||||
fn fail_job(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
job_id: i32,
|
||||
error_message: &str,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
/// Cancel a specific job by id. Only updates if the job is still
|
||||
/// in "running" status. Returns true if a row was updated.
|
||||
fn cancel_job(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
job_id: i32,
|
||||
) -> Result<bool, DbError>;
|
||||
|
||||
/// Cancel all running jobs for a given file. Returns the number of
|
||||
/// jobs cancelled.
|
||||
fn cancel_active_jobs(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
file_path: &str,
|
||||
) -> Result<usize, DbError>;
|
||||
|
||||
/// Find the latest running job for a given file. Returns None if no
|
||||
/// running job exists.
|
||||
fn get_active_job(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
file_path: &str,
|
||||
) -> Result<Option<InsightGenerationJob>, DbError>;
|
||||
|
||||
/// Find any job by id regardless of status.
|
||||
fn get_job_by_id(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
job_id: i32,
|
||||
) -> Result<Option<InsightGenerationJob>, DbError>;
|
||||
|
||||
/// Mark all jobs still in "running" status as "failed" with a recovery
|
||||
/// error message. Returns the number of jobs recovered.
|
||||
fn recover_orphaned_jobs(&mut self, context: &opentelemetry::Context)
|
||||
-> Result<usize, DbError>;
|
||||
}
|
||||
|
||||
pub struct SqliteInsightGenerationJobDao {
|
||||
connection: Arc<Mutex<SqliteConnection>>,
|
||||
}
|
||||
|
||||
impl Default for SqliteInsightGenerationJobDao {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SqliteInsightGenerationJobDao {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connection: Arc::new(Mutex::new(connect())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
|
||||
Self { connection: conn }
|
||||
}
|
||||
}
|
||||
|
||||
impl InsightGenerationJobDao for SqliteInsightGenerationJobDao {
|
||||
fn create_job(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
file_path: &str,
|
||||
generation_type: InsightGenerationType,
|
||||
) -> Result<i32, DbError> {
|
||||
trace_db_call(context, "insert", "create_job", |_span| {
|
||||
use schema::insight_generation_jobs::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock InsightGenerationJobDao");
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs() as i64;
|
||||
|
||||
let new_job = InsertInsightGenerationJob {
|
||||
library_id,
|
||||
path: file_path.to_string(),
|
||||
gen_type: generation_type.to_string(),
|
||||
status: InsightJobStatus::Running.to_string(),
|
||||
started_at: now,
|
||||
};
|
||||
|
||||
diesel::insert_into(dsl::insight_generation_jobs)
|
||||
.values(&new_job)
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to insert job: {}", e))?;
|
||||
|
||||
dsl::insight_generation_jobs
|
||||
.filter(
|
||||
dsl::library_id
|
||||
.eq(library_id)
|
||||
.and(dsl::file_path.eq(file_path))
|
||||
.and(dsl::generation_type.eq(generation_type.as_str()))
|
||||
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
|
||||
)
|
||||
.select(dsl::id)
|
||||
.order(dsl::id.desc())
|
||||
.first::<i32>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get job id: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn complete_job(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
job_id: i32,
|
||||
insight_id: i32,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "update", "complete_job", |_span| {
|
||||
use schema::insight_generation_jobs::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock InsightGenerationJobDao");
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs() as i64;
|
||||
|
||||
// Only update if still running — prevents cancelled job from
|
||||
// being overwritten by a late-completing task.
|
||||
diesel::update(
|
||||
dsl::insight_generation_jobs.filter(
|
||||
dsl::id
|
||||
.eq(job_id)
|
||||
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
|
||||
),
|
||||
)
|
||||
.set((
|
||||
dsl::status.eq(InsightJobStatus::Completed.as_str()),
|
||||
dsl::completed_at.eq(Some(now)),
|
||||
dsl::result_insight_id.eq(Some(insight_id)),
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to complete job: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
}
|
||||
|
||||
fn fail_job(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
job_id: i32,
|
||||
error_message: &str,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "update", "fail_job", |_span| {
|
||||
use schema::insight_generation_jobs::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock InsightGenerationJobDao");
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs() as i64;
|
||||
|
||||
// Only update if still running.
|
||||
diesel::update(
|
||||
dsl::insight_generation_jobs.filter(
|
||||
dsl::id
|
||||
.eq(job_id)
|
||||
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
|
||||
),
|
||||
)
|
||||
.set((
|
||||
dsl::status.eq(InsightJobStatus::Failed.as_str()),
|
||||
dsl::completed_at.eq(Some(now)),
|
||||
dsl::error_message.eq(Some(error_message.to_string())),
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to fail job: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
}
|
||||
|
||||
fn cancel_job(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
job_id: i32,
|
||||
) -> Result<bool, DbError> {
|
||||
trace_db_call(context, "update", "cancel_job", |_span| {
|
||||
use schema::insight_generation_jobs::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock InsightGenerationJobDao");
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs() as i64;
|
||||
|
||||
let rows = diesel::update(
|
||||
dsl::insight_generation_jobs.filter(
|
||||
dsl::id
|
||||
.eq(job_id)
|
||||
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
|
||||
),
|
||||
)
|
||||
.set((
|
||||
dsl::status.eq(InsightJobStatus::Cancelled.as_str()),
|
||||
dsl::completed_at.eq(Some(now)),
|
||||
dsl::error_message.eq(Some("cancelled by user".to_string())),
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to cancel job: {}", e))?;
|
||||
|
||||
Ok(rows > 0)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
}
|
||||
|
||||
fn cancel_active_jobs(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
file_path: &str,
|
||||
) -> Result<usize, DbError> {
|
||||
trace_db_call(context, "update", "cancel_active_jobs", |_span| {
|
||||
use schema::insight_generation_jobs::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock InsightGenerationJobDao");
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs() as i64;
|
||||
|
||||
let rows = diesel::update(
|
||||
dsl::insight_generation_jobs.filter(
|
||||
dsl::library_id
|
||||
.eq(library_id)
|
||||
.and(dsl::file_path.eq(file_path))
|
||||
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
|
||||
),
|
||||
)
|
||||
.set((
|
||||
dsl::status.eq(InsightJobStatus::Cancelled.as_str()),
|
||||
dsl::completed_at.eq(Some(now)),
|
||||
dsl::error_message.eq(Some("cancelled by newer request".to_string())),
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to cancel active jobs: {}", e))?;
|
||||
|
||||
Ok(rows)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
}
|
||||
|
||||
fn get_active_job(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
file_path: &str,
|
||||
) -> Result<Option<InsightGenerationJob>, DbError> {
|
||||
trace_db_call(context, "query", "get_active_job", |_span| {
|
||||
use schema::insight_generation_jobs::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock InsightGenerationJobDao");
|
||||
|
||||
dsl::insight_generation_jobs
|
||||
.filter(
|
||||
dsl::library_id
|
||||
.eq(library_id)
|
||||
.and(dsl::file_path.eq(file_path))
|
||||
.and(dsl::status.eq(InsightJobStatus::Running.as_str())),
|
||||
)
|
||||
.order(dsl::id.desc())
|
||||
.first::<InsightGenerationJob>(connection.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get active job: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn get_job_by_id(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
job_id: i32,
|
||||
) -> Result<Option<InsightGenerationJob>, DbError> {
|
||||
trace_db_call(context, "query", "get_job_by_id", |_span| {
|
||||
use schema::insight_generation_jobs::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock InsightGenerationJobDao");
|
||||
|
||||
dsl::insight_generation_jobs
|
||||
.filter(dsl::id.eq(job_id))
|
||||
.first::<InsightGenerationJob>(connection.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get job: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn recover_orphaned_jobs(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
) -> Result<usize, DbError> {
|
||||
trace_db_call(context, "update", "recover_orphaned_jobs", |_span| {
|
||||
use schema::insight_generation_jobs::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock InsightGenerationJobDao");
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs() as i64;
|
||||
|
||||
let rows = diesel::update(
|
||||
dsl::insight_generation_jobs
|
||||
.filter(dsl::status.eq(InsightJobStatus::Running.as_str())),
|
||||
)
|
||||
.set((
|
||||
dsl::status.eq(InsightJobStatus::Failed.as_str()),
|
||||
dsl::completed_at.eq(Some(now)),
|
||||
dsl::error_message.eq(Some("server crashed while running".to_string())),
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to recover orphaned jobs: {}", e))?;
|
||||
|
||||
Ok(rows)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use diesel::Connection;
|
||||
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
|
||||
|
||||
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
||||
|
||||
fn setup_dao() -> SqliteInsightGenerationJobDao {
|
||||
let mut conn = SqliteConnection::establish(":memory:")
|
||||
.expect("Unable to create in-memory db connection");
|
||||
conn.run_pending_migrations(DB_MIGRATIONS)
|
||||
.expect("Failure running DB migrations");
|
||||
SqliteInsightGenerationJobDao::from_connection(Arc::new(Mutex::new(conn)))
|
||||
}
|
||||
|
||||
fn ctx() -> opentelemetry::Context {
|
||||
opentelemetry::Context::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_job_inserts_new_row() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let job_id_1 = dao
|
||||
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
|
||||
.unwrap();
|
||||
|
||||
let job_id_2 = dao
|
||||
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(job_id_1, job_id_2, "each create_job call inserts a new row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_job_sets_result() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let job_id = dao
|
||||
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
|
||||
.unwrap();
|
||||
|
||||
dao.complete_job(&ctx, job_id, 42).unwrap();
|
||||
|
||||
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
|
||||
assert_eq!(job.status, InsightJobStatus::Completed.as_str());
|
||||
assert_eq!(job.result_insight_id, Some(42));
|
||||
assert!(job.completed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_job_sets_error() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let job_id = dao
|
||||
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Agentic)
|
||||
.unwrap();
|
||||
|
||||
dao.fail_job(&ctx, job_id, "model timeout").unwrap();
|
||||
|
||||
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
|
||||
assert_eq!(job.status, InsightJobStatus::Failed.as_str());
|
||||
assert_eq!(job.error_message.as_deref(), Some("model timeout"));
|
||||
assert!(job.completed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_active_job_returns_none_when_completed() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let job_id = dao
|
||||
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
|
||||
.unwrap();
|
||||
|
||||
// Job is running
|
||||
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
|
||||
assert!(active.is_some());
|
||||
assert_eq!(active.unwrap().id, job_id);
|
||||
|
||||
// Complete it
|
||||
dao.complete_job(&ctx, job_id, 1).unwrap();
|
||||
|
||||
// No longer active
|
||||
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
|
||||
assert!(active.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_active_jobs() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let job_id = dao
|
||||
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
|
||||
.unwrap();
|
||||
|
||||
let cancelled = dao.cancel_active_jobs(&ctx, 1, "photos/test.jpg").unwrap();
|
||||
assert_eq!(cancelled, 1, "should cancel 1 running job");
|
||||
|
||||
// Job is no longer active
|
||||
let active = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
|
||||
assert!(active.is_none());
|
||||
|
||||
// Job exists with cancelled status
|
||||
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
|
||||
assert_eq!(job.status, InsightJobStatus::Cancelled.as_str());
|
||||
|
||||
// Cancelling again returns 0 (nothing to cancel)
|
||||
let cancelled2 = dao.cancel_active_jobs(&ctx, 1, "photos/test.jpg").unwrap();
|
||||
assert_eq!(cancelled2, 0, "should return 0 when no running job");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_active_job_scoped_by_library() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let job_id_1 = dao
|
||||
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
|
||||
.unwrap();
|
||||
|
||||
let job_id_2 = dao
|
||||
.create_job(&ctx, 2, "photos/test.jpg", InsightGenerationType::Standard)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(
|
||||
job_id_1, job_id_2,
|
||||
"different libraries should have separate jobs"
|
||||
);
|
||||
|
||||
// Complete lib1's job
|
||||
dao.complete_job(&ctx, job_id_1, 1).unwrap();
|
||||
|
||||
// lib1 has no active job
|
||||
let active1 = dao.get_active_job(&ctx, 1, "photos/test.jpg").unwrap();
|
||||
assert!(active1.is_none());
|
||||
|
||||
// lib2 still has active job
|
||||
let active2 = dao.get_active_job(&ctx, 2, "photos/test.jpg").unwrap();
|
||||
assert!(active2.is_some());
|
||||
assert_eq!(active2.unwrap().id, job_id_2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_job_by_id_finds_any_status() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let job_id = dao
|
||||
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
|
||||
.unwrap();
|
||||
|
||||
// Find while running
|
||||
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
|
||||
assert_eq!(job.status, InsightJobStatus::Running.as_str());
|
||||
|
||||
// Complete it
|
||||
dao.complete_job(&ctx, job_id, 99).unwrap();
|
||||
|
||||
// Still findable
|
||||
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
|
||||
assert_eq!(job.status, InsightJobStatus::Completed.as_str());
|
||||
assert_eq!(job.result_insight_id, Some(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_orphaned_jobs() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
// Create two running jobs
|
||||
let job_id_1 = dao
|
||||
.create_job(&ctx, 1, "photos/a.jpg", InsightGenerationType::Standard)
|
||||
.unwrap();
|
||||
let job_id_2 = dao
|
||||
.create_job(&ctx, 1, "photos/b.jpg", InsightGenerationType::Agentic)
|
||||
.unwrap();
|
||||
|
||||
// Complete one
|
||||
dao.complete_job(&ctx, job_id_1, 1).unwrap();
|
||||
|
||||
// Recover should only affect the running job
|
||||
let recovered = dao.recover_orphaned_jobs(&ctx).unwrap();
|
||||
assert_eq!(recovered, 1, "should recover exactly 1 running job");
|
||||
|
||||
// job_id_1 is still completed
|
||||
let job1 = dao.get_job_by_id(&ctx, job_id_1).unwrap().unwrap();
|
||||
assert_eq!(job1.status, InsightJobStatus::Completed.as_str());
|
||||
|
||||
// job_id_2 is now failed with recovery message
|
||||
let job2 = dao.get_job_by_id(&ctx, job_id_2).unwrap().unwrap();
|
||||
assert_eq!(job2.status, InsightJobStatus::Failed.as_str());
|
||||
assert_eq!(
|
||||
job2.error_message.as_deref(),
|
||||
Some("server crashed while running")
|
||||
);
|
||||
|
||||
// Second recovery is a no-op
|
||||
let recovered2 = dao.recover_orphaned_jobs(&ctx).unwrap();
|
||||
assert_eq!(recovered2, 0, "no running jobs remain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_job_noop_when_cancelled() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let job_id = dao
|
||||
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
|
||||
.unwrap();
|
||||
|
||||
dao.cancel_job(&ctx, job_id).unwrap();
|
||||
|
||||
// Late-completing task tries to mark as completed — should be a no-op
|
||||
dao.complete_job(&ctx, job_id, 42).unwrap();
|
||||
|
||||
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
job.status,
|
||||
InsightJobStatus::Cancelled.as_str(),
|
||||
"cancelled status must not be overwritten by late complete"
|
||||
);
|
||||
assert_eq!(
|
||||
job.result_insight_id, None,
|
||||
"insight_id must stay None when complete is a no-op"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_job_noop_when_cancelled() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let job_id = dao
|
||||
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Agentic)
|
||||
.unwrap();
|
||||
|
||||
dao.cancel_job(&ctx, job_id).unwrap();
|
||||
|
||||
// Late-failing task tries to mark as failed — should be a no-op
|
||||
dao.fail_job(&ctx, job_id, "timeout after 120s").unwrap();
|
||||
|
||||
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
job.status,
|
||||
InsightJobStatus::Cancelled.as_str(),
|
||||
"cancelled status must not be overwritten by late fail"
|
||||
);
|
||||
assert_eq!(
|
||||
job.error_message.as_deref(),
|
||||
Some("cancelled by user"),
|
||||
"error_message must reflect the cancel, not the late fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_job_by_id() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let job_id = dao
|
||||
.create_job(&ctx, 1, "photos/test.jpg", InsightGenerationType::Standard)
|
||||
.unwrap();
|
||||
|
||||
let cancelled = dao.cancel_job(&ctx, job_id).unwrap();
|
||||
assert!(cancelled, "should cancel running job");
|
||||
|
||||
let job = dao.get_job_by_id(&ctx, job_id).unwrap().unwrap();
|
||||
assert_eq!(job.status, InsightJobStatus::Cancelled.as_str());
|
||||
assert!(job.completed_at.is_some());
|
||||
|
||||
// Cancelling again is a no-op
|
||||
let cancelled2 = dao.cancel_job(&ctx, job_id).unwrap();
|
||||
assert!(!cancelled2, "already cancelled job should return false");
|
||||
}
|
||||
}
|
||||
+28
-149
@@ -47,6 +47,7 @@ pub trait InsightDao: Sync + Send {
|
||||
paths: &[String],
|
||||
) -> Result<Option<PhotoInsight>, DbError>;
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn get_insight_history(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
@@ -81,17 +82,6 @@ pub trait InsightDao: Sync + Send {
|
||||
approved: bool,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
/// Rate a specific insight version by primary key, regardless of
|
||||
/// `is_current`. Used by the per-file history view to approve/reject
|
||||
/// previously generated (superseded) versions, which the path-based
|
||||
/// `rate_insight` (current row only) cannot reach.
|
||||
fn rate_insight_by_id(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
insight_id: i32,
|
||||
approved: bool,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
fn get_approved_insights(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
@@ -100,15 +90,13 @@ pub trait InsightDao: Sync + Send {
|
||||
/// Replace the `training_messages` JSON blob on the current row for
|
||||
/// `(library_id, rel_path)`. Used by chat-turn append mode to persist
|
||||
/// the extended conversation without inserting a new insight version.
|
||||
/// Returns the number of rows affected (0 if no current row matched,
|
||||
/// indicating a concurrent regenerate/reconcile flipped `is_current`).
|
||||
fn update_training_messages(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
file_path: &str,
|
||||
training_messages_json: &str,
|
||||
) -> Result<usize, DbError>;
|
||||
) -> Result<(), DbError>;
|
||||
}
|
||||
|
||||
pub struct SqliteInsightDao {
|
||||
@@ -171,13 +159,13 @@ impl InsightDao for SqliteInsightDao {
|
||||
)
|
||||
.set(is_current.eq(false))
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to flip is_current: {}", e))?;
|
||||
.map_err(|_| anyhow::anyhow!("Update is_current error"))?;
|
||||
|
||||
// Insert the new insight as current
|
||||
diesel::insert_into(photo_insights)
|
||||
.values(&insight)
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to insert insight: {}", e))?;
|
||||
.map_err(|_| anyhow::anyhow!("Insert error"))?;
|
||||
|
||||
// Retrieve the inserted record (is_current = true)
|
||||
photo_insights
|
||||
@@ -185,12 +173,9 @@ impl InsightDao for SqliteInsightDao {
|
||||
.filter(rel_path.eq(&insight.file_path))
|
||||
.filter(is_current.eq(true))
|
||||
.first::<PhotoInsight>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to retrieve inserted insight: {}", e))
|
||||
})
|
||||
.map_err(|e| {
|
||||
log::error!("store_insight failed: {}", e);
|
||||
DbError::new(DbErrorKind::InsertError)
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn get_insight(
|
||||
@@ -208,9 +193,9 @@ impl InsightDao for SqliteInsightDao {
|
||||
.filter(is_current.eq(true))
|
||||
.first::<PhotoInsight>(connection.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_current_insight_for_library(
|
||||
@@ -234,10 +219,10 @@ impl InsightDao for SqliteInsightDao {
|
||||
.filter(is_current.eq(true))
|
||||
.first::<PhotoInsight>(connection.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
},
|
||||
)
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_insight_for_paths(
|
||||
@@ -259,9 +244,9 @@ impl InsightDao for SqliteInsightDao {
|
||||
.order(generated_at.desc())
|
||||
.first::<PhotoInsight>(connection.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_insight_history(
|
||||
@@ -278,9 +263,9 @@ impl InsightDao for SqliteInsightDao {
|
||||
.filter(rel_path.eq(path))
|
||||
.order(generated_at.desc())
|
||||
.load::<PhotoInsight>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_insight_by_id(
|
||||
@@ -297,9 +282,9 @@ impl InsightDao for SqliteInsightDao {
|
||||
.find(insight_id)
|
||||
.first::<PhotoInsight>(connection.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn delete_insight(
|
||||
@@ -315,9 +300,9 @@ impl InsightDao for SqliteInsightDao {
|
||||
diesel::delete(photo_insights.filter(rel_path.eq(path)))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Delete error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_all_insights(
|
||||
@@ -333,9 +318,9 @@ impl InsightDao for SqliteInsightDao {
|
||||
.filter(is_current.eq(true))
|
||||
.order(generated_at.desc())
|
||||
.load::<PhotoInsight>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn rate_insight(
|
||||
@@ -357,29 +342,9 @@ impl InsightDao for SqliteInsightDao {
|
||||
.set(approved.eq(Some(is_approved)))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Update error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
}
|
||||
|
||||
fn rate_insight_by_id(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
target_id: i32,
|
||||
is_approved: bool,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "update", "rate_insight_by_id", |_span| {
|
||||
use schema::photo_insights::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
|
||||
|
||||
diesel::update(photo_insights.find(target_id))
|
||||
.set(approved.eq(Some(is_approved)))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn get_approved_insights(
|
||||
@@ -396,9 +361,9 @@ impl InsightDao for SqliteInsightDao {
|
||||
.filter(training_messages.is_not_null())
|
||||
.order(generated_at.desc())
|
||||
.load::<PhotoInsight>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn update_training_messages(
|
||||
@@ -407,7 +372,7 @@ impl InsightDao for SqliteInsightDao {
|
||||
lib_id: i32,
|
||||
path: &str,
|
||||
training_messages_json: &str,
|
||||
) -> Result<usize, DbError> {
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "update", "update_training_messages", |_span| {
|
||||
use schema::photo_insights::dsl::*;
|
||||
|
||||
@@ -421,95 +386,9 @@ impl InsightDao for SqliteInsightDao {
|
||||
)
|
||||
.set(training_messages.eq(Some(training_messages_json.to_string())))
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
.map(|_| ())
|
||||
.map_err(|_| anyhow::anyhow!("Update error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::test::in_memory_db_connection;
|
||||
|
||||
fn dao() -> SqliteInsightDao {
|
||||
let conn = Arc::new(Mutex::new(in_memory_db_connection()));
|
||||
SqliteInsightDao::from_connection(conn)
|
||||
}
|
||||
|
||||
/// Build an insight insert with sensible defaults; tests override the
|
||||
/// fields they care about (path, generated_at, model).
|
||||
fn insert(path: &str, generated_at: i64, model: &str) -> InsertPhotoInsight {
|
||||
InsertPhotoInsight {
|
||||
library_id: 1,
|
||||
file_path: path.to_string(),
|
||||
title: format!("title for {model}"),
|
||||
summary: "summary".to_string(),
|
||||
generated_at,
|
||||
model_version: model.to_string(),
|
||||
is_current: true,
|
||||
training_messages: None,
|
||||
backend: "local".to_string(),
|
||||
fewshot_source_ids: None,
|
||||
content_hash: None,
|
||||
num_ctx: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
top_k: None,
|
||||
min_p: None,
|
||||
system_prompt: None,
|
||||
persona_id: None,
|
||||
prompt_eval_count: None,
|
||||
eval_count: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_insight_history_returns_all_versions_newest_first() {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let mut dao = dao();
|
||||
|
||||
// store_insight flips prior rows to is_current=false, so three
|
||||
// generations for the same path leave a 3-row history.
|
||||
dao.store_insight(&cx, insert("a.jpg", 100, "m1")).unwrap();
|
||||
dao.store_insight(&cx, insert("a.jpg", 200, "m2")).unwrap();
|
||||
dao.store_insight(&cx, insert("a.jpg", 300, "m3")).unwrap();
|
||||
// A different path must not leak into the history.
|
||||
dao.store_insight(&cx, insert("b.jpg", 250, "other"))
|
||||
.unwrap();
|
||||
|
||||
let history = dao.get_insight_history(&cx, "a.jpg").unwrap();
|
||||
assert_eq!(history.len(), 3);
|
||||
assert_eq!(
|
||||
history.iter().map(|i| i.generated_at).collect::<Vec<_>>(),
|
||||
vec![300, 200, 100],
|
||||
"history should be newest-first"
|
||||
);
|
||||
// Exactly one version is current (the latest generation).
|
||||
let current: Vec<_> = history.iter().filter(|i| i.is_current).collect();
|
||||
assert_eq!(current.len(), 1);
|
||||
assert_eq!(current[0].generated_at, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_insight_by_id_rates_only_the_targeted_version() {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let mut dao = dao();
|
||||
|
||||
dao.store_insight(&cx, insert("a.jpg", 100, "m1")).unwrap();
|
||||
dao.store_insight(&cx, insert("a.jpg", 200, "m2")).unwrap();
|
||||
|
||||
// History is newest-first: [200 (current), 100 (superseded)].
|
||||
let history = dao.get_insight_history(&cx, "a.jpg").unwrap();
|
||||
let old_version = history.iter().find(|i| i.generated_at == 100).unwrap();
|
||||
assert!(!old_version.is_current);
|
||||
|
||||
dao.rate_insight_by_id(&cx, old_version.id, true).unwrap();
|
||||
|
||||
let history = dao.get_insight_history(&cx, "a.jpg").unwrap();
|
||||
let old = history.iter().find(|i| i.generated_at == 100).unwrap();
|
||||
let current = history.iter().find(|i| i.generated_at == 200).unwrap();
|
||||
assert_eq!(old.approved, Some(true), "targeted version is rated");
|
||||
assert_eq!(current.approved, None, "current version is untouched");
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,6 @@ pub trait KnowledgeDao: Sync + Send {
|
||||
/// - entity_type: optional, restricts nodes to one type
|
||||
/// - node_limit: caps the number of nodes; lower-fact-count
|
||||
/// entities drop first
|
||||
///
|
||||
/// Edges between dropped entities are pruned. Persona scoping
|
||||
/// affects fact_count + edge inclusion (rejected / superseded
|
||||
/// excluded; All vs Single mirrors the existing pattern).
|
||||
@@ -582,7 +581,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
}
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn get_entity_by_id(
|
||||
@@ -599,7 +598,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_entity_by_name(
|
||||
@@ -624,7 +623,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.load::<Entity>(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_entities_with_embeddings(
|
||||
@@ -649,7 +648,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.load::<Entity>(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn list_entities(
|
||||
@@ -706,7 +705,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
|
||||
Ok((results, total))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn list_entities_with_fact_counts(
|
||||
@@ -894,7 +893,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
|
||||
Ok((pairs, total))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_predicate_stats(
|
||||
@@ -938,10 +937,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
let mut conn = self.connection.lock().expect("KnowledgeDao lock");
|
||||
let mut q = sql_query(sql).into_boxed();
|
||||
match persona {
|
||||
PersonaFilter::Single {
|
||||
user_id,
|
||||
persona_id,
|
||||
} => {
|
||||
PersonaFilter::Single { user_id, persona_id } => {
|
||||
q = q
|
||||
.bind::<Integer, _>(*user_id)
|
||||
.bind::<Text, _>(persona_id.clone());
|
||||
@@ -957,7 +953,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?;
|
||||
Ok(rows.into_iter().map(|r| (r.predicate, r.cnt)).collect())
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn bulk_reject_facts_by_predicate(
|
||||
@@ -981,10 +977,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
// rows flip — REVIEWED survives so the curator can preserve
|
||||
// a hand-approved exception under the same predicate.
|
||||
let touched = match persona {
|
||||
PersonaFilter::Single {
|
||||
user_id: uid,
|
||||
persona_id: pid,
|
||||
} => diesel::update(
|
||||
PersonaFilter::Single { user_id: uid, persona_id: pid } => diesel::update(
|
||||
entity_facts
|
||||
.filter(predicate.eq(target_predicate))
|
||||
.filter(user_id.eq(*uid))
|
||||
@@ -1016,7 +1009,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
};
|
||||
Ok(touched)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn build_entity_graph(
|
||||
@@ -1194,7 +1187,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
|
||||
Ok(EntityGraph { nodes, edges })
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn find_consolidation_proposals(
|
||||
@@ -1289,7 +1282,8 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
for &ib in &indices[a + 1..] {
|
||||
for b in (a + 1)..indices.len() {
|
||||
let ib = indices[b];
|
||||
let vb = match &decoded[ib] {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
@@ -1349,7 +1343,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
result.truncate(max_groups);
|
||||
Ok(result)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_persona_breakdowns_for_entities(
|
||||
@@ -1411,7 +1405,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn update_entity_status(
|
||||
@@ -1429,7 +1423,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn update_entity(
|
||||
@@ -1475,7 +1469,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn delete_entity(
|
||||
@@ -1565,7 +1559,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("Merge transaction error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1636,7 +1630,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
Ok((inserted, true)) // true = newly created
|
||||
}
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn get_facts_for_entity(
|
||||
@@ -1662,7 +1656,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
q.load::<EntityFact>(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn list_facts(
|
||||
@@ -1719,7 +1713,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
|
||||
Ok((results, total))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn update_fact(
|
||||
@@ -1801,7 +1795,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn update_facts_insight_id(
|
||||
@@ -1823,7 +1817,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn delete_fact(&mut self, cx: &opentelemetry::Context, fact_id: i32) -> Result<(), DbError> {
|
||||
@@ -2015,7 +2009,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn delete_photo_links_for_file(
|
||||
@@ -2031,7 +2025,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_links_for_photo(
|
||||
@@ -2047,7 +2041,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.load::<EntityPhotoLink>(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_links_for_entity(
|
||||
@@ -2063,7 +2057,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.load::<EntityPhotoLink>(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -2111,7 +2105,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
facts: recent_facts,
|
||||
})
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -216,12 +216,11 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
|
||||
|
||||
// Validate embedding dimensions if provided (rare for location data)
|
||||
if let Some(ref emb) = location.embedding
|
||||
&& emb.len() != crate::ai::embedding_dim()
|
||||
&& emb.len() != 768
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid embedding dimensions: {} (expected {})",
|
||||
emb.len(),
|
||||
crate::ai::embedding_dim()
|
||||
"Invalid embedding dimensions: {} (expected 768)",
|
||||
emb.len()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -274,7 +273,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
|
||||
source_file: location.source_file,
|
||||
})
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn store_locations_batch(
|
||||
@@ -293,7 +292,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
|
||||
for location in locations {
|
||||
// Validate embedding if provided (rare)
|
||||
if let Some(ref emb) = location.embedding
|
||||
&& emb.len() != crate::ai::embedding_dim()
|
||||
&& emb.len() != 768
|
||||
{
|
||||
log::warn!(
|
||||
"Skipping location with invalid embedding dimensions: {}",
|
||||
@@ -351,7 +350,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
|
||||
|
||||
Ok(inserted)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn find_nearest_location(
|
||||
@@ -386,7 +385,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
|
||||
|
||||
Ok(results.into_iter().next().map(|r| r.to_location_record()))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn find_locations_in_range(
|
||||
@@ -414,7 +413,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
|
||||
.map(|rows| rows.into_iter().map(|r| r.to_location_record()).collect())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {:?}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn find_locations_near_point(
|
||||
@@ -469,7 +468,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
|
||||
|
||||
Ok(filtered)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn location_exists(
|
||||
@@ -503,7 +502,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
|
||||
|
||||
Ok(result.count > 0)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_location_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> {
|
||||
@@ -526,6 +525,6 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
|
||||
|
||||
Ok(result.count)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
|
||||
+76
-376
@@ -45,22 +45,18 @@ pub struct DuplicateRow {
|
||||
|
||||
pub mod calendar_dao;
|
||||
pub mod daily_summary_dao;
|
||||
pub mod insight_generation_job_dao;
|
||||
pub mod insights_dao;
|
||||
pub mod knowledge_dao;
|
||||
pub mod location_dao;
|
||||
pub mod models;
|
||||
pub mod persona_dao;
|
||||
pub mod precomputed_reel_dao;
|
||||
pub mod preview_dao;
|
||||
pub mod reconcile;
|
||||
pub mod schema;
|
||||
pub mod search_dao;
|
||||
pub mod user_ai_prefs_dao;
|
||||
|
||||
pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao};
|
||||
pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao};
|
||||
pub use insight_generation_job_dao::{InsightGenerationJobDao, SqliteInsightGenerationJobDao};
|
||||
pub use insights_dao::{InsightDao, SqliteInsightDao};
|
||||
pub use knowledge_dao::{
|
||||
ConsolidationGroup, EntityFilter, EntityGraph, EntityPatch, EntitySort, FactFilter, FactPatch,
|
||||
@@ -68,10 +64,8 @@ pub use knowledge_dao::{
|
||||
};
|
||||
pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao};
|
||||
pub use persona_dao::{ImportPersona, PersonaDao, PersonaPatch, SqlitePersonaDao};
|
||||
pub use precomputed_reel_dao::{PrecomputedReelDao, SqlitePrecomputedReelDao};
|
||||
pub use preview_dao::{PreviewDao, SqlitePreviewDao};
|
||||
pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao};
|
||||
pub use user_ai_prefs_dao::{SqliteUserAiPrefsDao, UserAiPrefsDao};
|
||||
|
||||
pub trait UserDao {
|
||||
fn create_user(&mut self, user: &str, password: &str) -> Option<User>;
|
||||
@@ -197,26 +191,14 @@ pub fn connect() -> SqliteConnection {
|
||||
conn
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DbError {
|
||||
pub kind: DbErrorKind,
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
impl DbError {
|
||||
fn new(kind: DbErrorKind) -> Self {
|
||||
DbError { kind, source: None }
|
||||
}
|
||||
|
||||
/// Capture the source error message AND log it. Callers should use
|
||||
/// this from `map_err` closures so the underlying Diesel/SQLite
|
||||
/// error survives the conversion to `DbError`.
|
||||
fn log(kind: DbErrorKind, source: impl std::fmt::Display) -> Self {
|
||||
let msg = source.to_string();
|
||||
log::error!("DB {:?}: {}", kind, msg);
|
||||
DbError {
|
||||
kind,
|
||||
source: Some(msg),
|
||||
}
|
||||
DbError { kind }
|
||||
}
|
||||
|
||||
fn exists() -> Self {
|
||||
@@ -224,26 +206,6 @@ impl DbError {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DbError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match &self.source {
|
||||
Some(s) => write!(f, "DbError {{ kind: {:?}, source: {} }}", self.kind, s),
|
||||
None => write!(f, "DbError {{ kind: {:?} }}", self.kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DbError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match &self.source {
|
||||
Some(s) => write!(f, "{:?}: {}", self.kind, s),
|
||||
None => write!(f, "{:?}", self.kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DbError {}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum DbErrorKind {
|
||||
AlreadyExists,
|
||||
@@ -298,7 +260,7 @@ impl FavoriteDao for SqliteFavoriteDao {
|
||||
path: favorite_path,
|
||||
})
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
} else {
|
||||
Err(DbError::exists())
|
||||
}
|
||||
@@ -319,7 +281,7 @@ impl FavoriteDao for SqliteFavoriteDao {
|
||||
favorites
|
||||
.filter(userid.eq(user_id))
|
||||
.load::<Favorite>(self.connection.lock().unwrap().deref_mut())
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn update_path(&mut self, old_path: &str, new_path: &str) -> Result<(), DbError> {
|
||||
@@ -328,7 +290,7 @@ impl FavoriteDao for SqliteFavoriteDao {
|
||||
diesel::update(favorites.filter(rel_path.eq(old_path)))
|
||||
.set(rel_path.eq(new_path))
|
||||
.execute(self.connection.lock().unwrap().deref_mut())
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))?;
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -339,7 +301,7 @@ impl FavoriteDao for SqliteFavoriteDao {
|
||||
.select(rel_path)
|
||||
.distinct()
|
||||
.load(self.connection.lock().unwrap().deref_mut())
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,27 +414,6 @@ pub trait ExifDao: Sync + Send {
|
||||
size_bytes: i64,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
/// Every distinct non-NULL `content_hash` across all libraries. Used
|
||||
/// by HLS orphan cleanup to identify hash dirs under `$VIDEO_PATH`
|
||||
/// whose source video no longer exists. Cheap query (single column,
|
||||
/// indexed) but unbounded in size — the result is a HashSet membership
|
||||
/// check, so a 100k-photo library produces ~100k strings.
|
||||
fn list_distinct_content_hashes(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
) -> Result<Vec<String>, DbError>;
|
||||
|
||||
/// Every row in `image_exif` for `library_id`, as
|
||||
/// `(rel_path, content_hash)`. The hash is Option because rows
|
||||
/// mid-backfill carry NULL. Used by HLS readiness stats; callers
|
||||
/// filter by extension client-side because the DB schema doesn't
|
||||
/// carry media type.
|
||||
fn list_paths_and_hashes_for_library(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
) -> Result<Vec<(String, Option<String>)>, DbError>;
|
||||
|
||||
/// Return image_exif rows that need their `date_taken` resolved by the
|
||||
/// canonical-date waterfall (see `crate::date_resolver`): `date_taken
|
||||
/// IS NULL`. Returns `(library_id, rel_path)`. The caller filters to
|
||||
@@ -508,61 +449,6 @@ pub trait ExifDao: Sync + Send {
|
||||
source: &str,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
/// Find image_exif rows needing a CLIP embedding for semantic search:
|
||||
/// `clip_embedding IS NULL AND content_hash IS NOT NULL`, ordered by id
|
||||
/// ASC, limited. Hash-less rows wait for `backfill_unhashed_backlog` to
|
||||
/// hash them first — embedding a row we can't key on bytes is wasted
|
||||
/// work that the next library/move detection would invalidate. Backed
|
||||
/// by the partial index `idx_image_exif_clip_backfill`.
|
||||
///
|
||||
/// Returns `(rel_path, content_hash)` for the given library only. Video
|
||||
/// rows are returned too (the underlying anti-join is shape-uniform);
|
||||
/// the caller filters them out via `file_types::is_image_file` before
|
||||
/// sending to Apollo, mirroring `face_watch::filter_excluded`.
|
||||
///
|
||||
/// **Model upgrades** (re-encoding everything on a new
|
||||
/// `APOLLO_CLIP_MODEL`) are handled out-of-band — run
|
||||
/// `UPDATE image_exif SET clip_embedding = NULL
|
||||
/// WHERE clip_model_version != '<new model>';`
|
||||
/// and the drain picks up the freshly-nulled rows on the next tick.
|
||||
/// Mixing in-flight model versions in a single query is intentionally
|
||||
/// not the drain's problem.
|
||||
fn list_clip_unencoded_candidates(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
limit: i64,
|
||||
) -> Result<Vec<(String, String)>, DbError>;
|
||||
|
||||
/// Persist a CLIP embedding for an existing row. Touches
|
||||
/// `clip_embedding` and `clip_model_version` only — leaves every
|
||||
/// other column alone so the drain can't accidentally clobber EXIF /
|
||||
/// hash / date-resolver state that other paths have written.
|
||||
fn backfill_clip_embedding(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
rel_path: &str,
|
||||
embedding: &[u8],
|
||||
model_version: &str,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
/// Load every `(content_hash, clip_embedding)` pair from the live
|
||||
/// image_exif rows for the given libraries, optionally filtered to a
|
||||
/// single `model_version` (cosine sim across mixed geometries is
|
||||
/// meaningless). Used by `/photos/search` to rerank against the query
|
||||
/// embedding in-memory.
|
||||
///
|
||||
/// Returns one pair per content_hash. If a hash appears under more
|
||||
/// than one library, the first row wins (Diesel's natural ORDER BY id
|
||||
/// ASC). Hash-less and embedding-less rows are filtered server-side.
|
||||
fn list_clip_index(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_ids: &[i32],
|
||||
model_version: Option<&str>,
|
||||
) -> Result<Vec<(String, Vec<u8>)>, DbError>;
|
||||
|
||||
/// Operator-driven date_taken override (POST /image/exif/date). Snapshots
|
||||
/// the prior `(date_taken, date_taken_source)` into the `original_*`
|
||||
/// pair on first override, then writes the new value with
|
||||
@@ -595,9 +481,9 @@ pub trait ExifDao: Sync + Send {
|
||||
/// whose calendar position matches the request's span:
|
||||
/// - `"day"` — same month + day-of-month (any year)
|
||||
/// - `"week"` — same week-of-year (SQLite `%W`, Monday-anchored —
|
||||
/// close to but not exactly ISO week 8601; the boundary cases
|
||||
/// at year-start/end can shift by ±1 vs the prior request-time
|
||||
/// `iso_week()` filter)
|
||||
/// close to but not exactly ISO week 8601; the
|
||||
/// boundary cases at year-start/end can shift by ±1
|
||||
/// vs the prior request-time `iso_week()` filter)
|
||||
/// - `"month"` — same month (any year)
|
||||
///
|
||||
/// `tz_offset_minutes` is applied to both sides of the strftime
|
||||
@@ -959,7 +845,7 @@ impl ExifDao for SqliteExifDao {
|
||||
.first::<ImageExif>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Post-insert lookup failed: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn get_exif(
|
||||
@@ -986,7 +872,7 @@ impl ExifDao for SqliteExifDao {
|
||||
Err(_) => Err(anyhow::anyhow!("Query error")),
|
||||
}
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn update_exif(
|
||||
@@ -1023,15 +909,15 @@ impl ExifDao for SqliteExifDao {
|
||||
last_modified.eq(&exif_data.last_modified),
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))?;
|
||||
.map_err(|_| anyhow::anyhow!("Update error"))?;
|
||||
|
||||
image_exif
|
||||
.filter(library_id.eq(exif_data.library_id))
|
||||
.filter(rel_path.eq(&exif_data.file_path))
|
||||
.first::<ImageExif>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn delete_exif(&mut self, context: &opentelemetry::Context, path: &str) -> Result<(), DbError> {
|
||||
@@ -1041,9 +927,9 @@ impl ExifDao for SqliteExifDao {
|
||||
diesel::delete(image_exif.filter(rel_path.eq(path)))
|
||||
.execute(self.connection.lock().unwrap().deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Delete error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_all_with_date_taken(
|
||||
@@ -1074,9 +960,9 @@ impl ExifDao for SqliteExifDao {
|
||||
.filter_map(|(path, dt)| dt.map(|ts| (path, ts)))
|
||||
.collect()
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_exif_batch(
|
||||
@@ -1100,9 +986,9 @@ impl ExifDao for SqliteExifDao {
|
||||
query
|
||||
.filter(rel_path.eq_any(file_paths))
|
||||
.load::<ImageExif>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn query_by_exif(
|
||||
@@ -1161,9 +1047,9 @@ impl ExifDao for SqliteExifDao {
|
||||
|
||||
query
|
||||
.load::<ImageExif>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_camera_makes(
|
||||
@@ -1188,9 +1074,9 @@ impl ExifDao for SqliteExifDao {
|
||||
.filter_map(|(make, cnt)| make.map(|m| (m, cnt)))
|
||||
.collect()
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn update_file_path(
|
||||
@@ -1207,10 +1093,10 @@ impl ExifDao for SqliteExifDao {
|
||||
diesel::update(image_exif.filter(rel_path.eq(old_path)))
|
||||
.set(rel_path.eq(new_path))
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))?;
|
||||
.map_err(|_| anyhow::anyhow!("Update error"))?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn get_all_file_paths(
|
||||
@@ -1225,9 +1111,9 @@ impl ExifDao for SqliteExifDao {
|
||||
image_exif
|
||||
.select(rel_path)
|
||||
.load(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_all_with_gps(
|
||||
@@ -1295,7 +1181,7 @@ impl ExifDao for SqliteExifDao {
|
||||
|
||||
Ok(filtered)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_rows_missing_hash(
|
||||
@@ -1314,9 +1200,9 @@ impl ExifDao for SqliteExifDao {
|
||||
.order(id.asc())
|
||||
.limit(limit)
|
||||
.load::<(i32, String)>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn backfill_content_hash(
|
||||
@@ -1340,53 +1226,9 @@ impl ExifDao for SqliteExifDao {
|
||||
.set((content_hash.eq(hash), size_bytes.eq(size_val)))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Update error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
}
|
||||
|
||||
fn list_distinct_content_hashes(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
) -> Result<Vec<String>, DbError> {
|
||||
trace_db_call(context, "query", "list_distinct_content_hashes", |_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
|
||||
image_exif
|
||||
.filter(content_hash.is_not_null())
|
||||
.select(content_hash)
|
||||
.distinct()
|
||||
.load::<Option<String>>(connection.deref_mut())
|
||||
.map(|rows| rows.into_iter().flatten().collect())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn list_paths_and_hashes_for_library(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
lib_id: i32,
|
||||
) -> Result<Vec<(String, Option<String>)>, DbError> {
|
||||
trace_db_call(
|
||||
context,
|
||||
"query",
|
||||
"list_paths_and_hashes_for_library",
|
||||
|_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
|
||||
image_exif
|
||||
.filter(library_id.eq(lib_id))
|
||||
.select((rel_path, content_hash))
|
||||
.load::<(String, Option<String>)>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
},
|
||||
)
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn get_rows_needing_date_backfill(
|
||||
@@ -1413,10 +1255,10 @@ impl ExifDao for SqliteExifDao {
|
||||
.order(id.asc())
|
||||
.limit(limit)
|
||||
.load::<(i32, String)>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
},
|
||||
)
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn backfill_date_taken(
|
||||
@@ -1480,146 +1322,6 @@ impl ExifDao for SqliteExifDao {
|
||||
})
|
||||
}
|
||||
|
||||
fn list_clip_unencoded_candidates(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id_val: i32,
|
||||
limit: i64,
|
||||
) -> Result<Vec<(String, String)>, DbError> {
|
||||
trace_db_call(
|
||||
context,
|
||||
"query",
|
||||
"list_clip_unencoded_candidates",
|
||||
|_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
|
||||
// Partial index `idx_image_exif_clip_backfill` covers the
|
||||
// (clip_embedding IS NULL AND content_hash IS NOT NULL)
|
||||
// filter; the planner hits it directly. ORDER BY id ASC
|
||||
// keeps drain progress monotone across ticks.
|
||||
image_exif
|
||||
.filter(library_id.eq(library_id_val))
|
||||
.filter(clip_embedding.is_null())
|
||||
.filter(content_hash.is_not_null())
|
||||
.select((rel_path, content_hash.assume_not_null()))
|
||||
.order(id.asc())
|
||||
.limit(limit)
|
||||
.load::<(String, String)>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
},
|
||||
)
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn backfill_clip_embedding(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id_val: i32,
|
||||
rel_path_val: &str,
|
||||
embedding: &[u8],
|
||||
model_version: &str,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "update", "backfill_clip_embedding", |_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
|
||||
let result = diesel::update(
|
||||
image_exif
|
||||
.filter(library_id.eq(library_id_val))
|
||||
.filter(rel_path.eq(rel_path_val)),
|
||||
)
|
||||
.set((
|
||||
clip_embedding.eq(embedding),
|
||||
clip_model_version.eq(model_version),
|
||||
))
|
||||
.execute(connection.deref_mut());
|
||||
|
||||
match result {
|
||||
Ok(rows) => {
|
||||
if rows == 0 {
|
||||
// Same race as backfill_date_taken — row vanished
|
||||
// between the candidate query and this write. Not
|
||||
// a hard error; the drain re-scans next tick.
|
||||
log::debug!(
|
||||
"backfill_clip_embedding: 0 rows matched lib={} {} \
|
||||
(row likely retired by missing-file scan)",
|
||||
library_id_val,
|
||||
rel_path_val
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(anyhow::anyhow!(
|
||||
"diesel update failed (lib={}, rel_path={}, model={}): {}",
|
||||
library_id_val,
|
||||
rel_path_val,
|
||||
model_version,
|
||||
e
|
||||
)),
|
||||
}
|
||||
})
|
||||
.map_err(|e| {
|
||||
log::warn!("backfill_clip_embedding: {}", e);
|
||||
DbError::new(DbErrorKind::UpdateError)
|
||||
})
|
||||
}
|
||||
|
||||
fn list_clip_index(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_ids_val: &[i32],
|
||||
model_version_filter: Option<&str>,
|
||||
) -> Result<Vec<(String, Vec<u8>)>, DbError> {
|
||||
trace_db_call(context, "query", "list_clip_index", |_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
|
||||
// Build the base filter. content_hash + clip_embedding both
|
||||
// need to be present for the row to be searchable.
|
||||
let mut query = image_exif
|
||||
.filter(content_hash.is_not_null())
|
||||
.filter(clip_embedding.is_not_null())
|
||||
.into_boxed();
|
||||
if !library_ids_val.is_empty() {
|
||||
query = query.filter(library_id.eq_any(library_ids_val));
|
||||
}
|
||||
if let Some(mv) = model_version_filter {
|
||||
query = query.filter(clip_model_version.eq(mv));
|
||||
}
|
||||
|
||||
// Order by id ASC so cross-library duplicates pick the
|
||||
// earliest-ingested row (stable across calls; the in-memory
|
||||
// matrix gets a deterministic row order). Group-by on
|
||||
// content_hash via post-filter — Diesel doesn't expose a
|
||||
// clean DISTINCT ON in this query shape.
|
||||
let rows: Vec<(String, Vec<u8>)> = query
|
||||
.select((
|
||||
content_hash.assume_not_null(),
|
||||
clip_embedding.assume_not_null(),
|
||||
))
|
||||
.order(id.asc())
|
||||
.load::<(String, Vec<u8>)>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?;
|
||||
|
||||
// Dedupe by hash, keeping the first occurrence. Cheap; sized
|
||||
// to ~14k entries on this library.
|
||||
let mut seen: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::with_capacity(rows.len());
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for (h, e) in rows {
|
||||
if seen.insert(h.clone()) {
|
||||
out.push((h, e));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn set_manual_date_taken(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
@@ -1777,7 +1479,7 @@ impl ExifDao for SqliteExifDao {
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn find_by_content_hash(
|
||||
@@ -1794,9 +1496,9 @@ impl ExifDao for SqliteExifDao {
|
||||
.filter(content_hash.eq(hash))
|
||||
.first::<ImageExif>(connection.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_rel_paths_sharing_content(
|
||||
@@ -1819,7 +1521,7 @@ impl ExifDao for SqliteExifDao {
|
||||
.select(content_hash)
|
||||
.first::<Option<String>>(connection.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))?
|
||||
.flatten();
|
||||
|
||||
let paths = match hash {
|
||||
@@ -1828,13 +1530,13 @@ impl ExifDao for SqliteExifDao {
|
||||
.select(rel_path)
|
||||
.distinct()
|
||||
.load::<String>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?,
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))?,
|
||||
None => vec![rel_path_val.to_string()],
|
||||
};
|
||||
|
||||
Ok(paths)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_rel_paths_for_library(
|
||||
@@ -1851,9 +1553,9 @@ impl ExifDao for SqliteExifDao {
|
||||
.filter(library_id.eq(library_id_val))
|
||||
.select(rel_path)
|
||||
.load::<String>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn find_content_hash_anywhere(
|
||||
@@ -1873,9 +1575,9 @@ impl ExifDao for SqliteExifDao {
|
||||
.first::<Option<String>>(connection.deref_mut())
|
||||
.optional()
|
||||
.map(|opt| opt.flatten())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_rel_paths_by_hash(
|
||||
@@ -1893,9 +1595,9 @@ impl ExifDao for SqliteExifDao {
|
||||
.select(rel_path)
|
||||
.distinct()
|
||||
.load::<String>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_rel_paths_for_hashes(
|
||||
@@ -1922,14 +1624,14 @@ impl ExifDao for SqliteExifDao {
|
||||
.select((content_hash.assume_not_null(), rel_path))
|
||||
.distinct()
|
||||
.load::<(String, String)>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?;
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))?;
|
||||
for (hash, path) in rows {
|
||||
out.entry(hash).or_default().push(path);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn list_rel_paths_for_libraries(
|
||||
@@ -1995,9 +1697,9 @@ impl ExifDao for SqliteExifDao {
|
||||
|
||||
query
|
||||
.load::<(i32, String)>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn delete_exif_by_library(
|
||||
@@ -2016,9 +1718,9 @@ impl ExifDao for SqliteExifDao {
|
||||
)
|
||||
.execute(self.connection.lock().unwrap().deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Delete error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn count_for_library(
|
||||
@@ -2033,9 +1735,9 @@ impl ExifDao for SqliteExifDao {
|
||||
.filter(library_id.eq(library_id_val))
|
||||
.count()
|
||||
.get_result::<i64>(self.connection.lock().unwrap().deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Count error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Count error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn list_rel_paths_for_library_page(
|
||||
@@ -2059,10 +1761,10 @@ impl ExifDao for SqliteExifDao {
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.load::<(i32, String)>(self.connection.lock().unwrap().deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
},
|
||||
)
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_rows_missing_perceptual_hash(
|
||||
@@ -2107,10 +1809,10 @@ impl ExifDao for SqliteExifDao {
|
||||
.order(id.asc())
|
||||
.limit(limit)
|
||||
.load::<(i32, String)>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
},
|
||||
)
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn backfill_perceptual_hash(
|
||||
@@ -2134,12 +1836,11 @@ impl ExifDao for SqliteExifDao {
|
||||
.set((phash_64.eq(phash_val), dhash_64.eq(dhash_val)))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Update error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn list_duplicates_exact(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
@@ -2166,7 +1867,7 @@ impl ExifDao for SqliteExifDao {
|
||||
q = q.filter(library_id.eq(lib));
|
||||
}
|
||||
q.load::<String>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))?
|
||||
};
|
||||
|
||||
if dup_hashes.is_empty() {
|
||||
@@ -2213,7 +1914,7 @@ impl ExifDao for SqliteExifDao {
|
||||
Option<i64>,
|
||||
)> = q
|
||||
.load(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?;
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
@@ -2232,10 +1933,9 @@ impl ExifDao for SqliteExifDao {
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn list_perceptual_candidates(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
@@ -2295,7 +1995,7 @@ impl ExifDao for SqliteExifDao {
|
||||
Option<i64>,
|
||||
)> = q
|
||||
.load(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?;
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))?;
|
||||
|
||||
// Dedup keyed on content_hash, keeping the first occurrence
|
||||
// (deterministic by the SQL ORDER BY: lowest library_id,
|
||||
@@ -2321,7 +2021,7 @@ impl ExifDao for SqliteExifDao {
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn list_image_paths(
|
||||
@@ -2346,9 +2046,9 @@ impl ExifDao for SqliteExifDao {
|
||||
q = q.filter(duplicate_of_hash.is_null());
|
||||
}
|
||||
q.load::<(i32, String)>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn lookup_duplicate_row(
|
||||
@@ -2408,9 +2108,9 @@ impl ExifDao for SqliteExifDao {
|
||||
duplicate_decided_at: r.10,
|
||||
})
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Query error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn set_duplicate_of(
|
||||
@@ -2437,9 +2137,9 @@ impl ExifDao for SqliteExifDao {
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Update error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn clear_duplicate_of(
|
||||
@@ -2464,9 +2164,9 @@ impl ExifDao for SqliteExifDao {
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Update error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn union_perceptual_tags(
|
||||
@@ -2504,9 +2204,9 @@ impl ExifDao for SqliteExifDao {
|
||||
.bind::<diesel::sql_types::Text, _>(survivor_hash)
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Tag union error: {}", e))
|
||||
.map_err(|_| anyhow::anyhow!("Tag union error"))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-176
@@ -1,76 +1,9 @@
|
||||
use crate::database::schema::{
|
||||
entities, entity_facts, entity_photo_links, favorites, image_exif, insight_generation_jobs,
|
||||
libraries, personas, photo_insights, precomputed_reels, user_ai_prefs, users,
|
||||
video_preview_clips,
|
||||
entities, entity_facts, entity_photo_links, favorites, image_exif, libraries, personas,
|
||||
photo_insights, users, video_preview_clips,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Possible statuses for an insight generation job.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, FromSqlRow)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InsightJobStatus {
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl InsightJobStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Running => "running",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
Self::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"running" => Self::Running,
|
||||
"completed" => Self::Completed,
|
||||
"failed" => Self::Failed,
|
||||
"cancelled" => Self::Cancelled,
|
||||
other => {
|
||||
log::warn!(
|
||||
"Unknown InsightJobStatus value: {:?}, treating as failed",
|
||||
other
|
||||
);
|
||||
Self::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InsightJobStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Type of insight generation (standard vs agentic).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InsightGenerationType {
|
||||
Standard,
|
||||
Agentic,
|
||||
}
|
||||
|
||||
impl InsightGenerationType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Standard => "standard",
|
||||
Self::Agentic => "agentic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InsightGenerationType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = users)]
|
||||
pub struct InsertUser<'a> {
|
||||
@@ -181,15 +114,6 @@ pub struct ImageExif {
|
||||
/// Snapshot of the prior `date_taken_source` taken on first manual
|
||||
/// override. NULL when no override is active.
|
||||
pub original_date_taken_source: Option<String>,
|
||||
/// L2-normalized CLIP image embedding (raw little-endian float32 bytes;
|
||||
/// length depends on the model — 768×4 for ViT-L/14, 512×4 for ViT-B/32).
|
||||
/// NULL until Apollo's CLIP service has encoded this photo via the
|
||||
/// backfill drain. Used by `/photos/search` for semantic queries.
|
||||
pub clip_embedding: Option<Vec<u8>>,
|
||||
/// Which CLIP model produced `clip_embedding` (e.g. `"ViT-L/14"`). A
|
||||
/// swap of `APOLLO_CLIP_MODEL` re-eligibilizes rows whose stored
|
||||
/// version differs so the drain rebuilds them.
|
||||
pub clip_model_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
@@ -219,15 +143,6 @@ pub struct InsertPhotoInsight {
|
||||
/// inserted before the hash is available stay null and the
|
||||
/// reconciliation pass backfills them.
|
||||
pub content_hash: Option<String>,
|
||||
pub num_ctx: Option<i32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub top_p: Option<f32>,
|
||||
pub top_k: Option<i32>,
|
||||
pub min_p: Option<f32>,
|
||||
pub system_prompt: Option<String>,
|
||||
pub persona_id: Option<String>,
|
||||
pub prompt_eval_count: Option<i32>,
|
||||
pub eval_count: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Queryable, Clone, Debug)]
|
||||
@@ -247,15 +162,6 @@ pub struct PhotoInsight {
|
||||
pub backend: String,
|
||||
pub fewshot_source_ids: Option<String>,
|
||||
pub content_hash: Option<String>,
|
||||
pub num_ctx: Option<i32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub top_p: Option<f32>,
|
||||
pub top_k: Option<i32>,
|
||||
pub min_p: Option<f32>,
|
||||
pub system_prompt: Option<String>,
|
||||
pub persona_id: Option<String>,
|
||||
pub prompt_eval_count: Option<i32>,
|
||||
pub eval_count: Option<i32>,
|
||||
}
|
||||
|
||||
// --- Libraries ---
|
||||
@@ -479,83 +385,3 @@ pub struct VideoPreviewClip {
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = insight_generation_jobs)]
|
||||
pub struct InsertInsightGenerationJob {
|
||||
pub library_id: i32,
|
||||
#[diesel(column_name = file_path)]
|
||||
pub path: String,
|
||||
#[diesel(column_name = generation_type)]
|
||||
pub gen_type: String,
|
||||
pub status: String,
|
||||
pub started_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Serialize, Clone, Debug)]
|
||||
pub struct InsightGenerationJob {
|
||||
pub id: i32,
|
||||
pub library_id: i32,
|
||||
#[diesel(column_name = file_path)]
|
||||
pub path: String,
|
||||
#[diesel(column_name = generation_type)]
|
||||
pub gen_type: String,
|
||||
pub status: String,
|
||||
pub started_at: i64,
|
||||
pub completed_at: Option<i64>,
|
||||
pub result_insight_id: Option<i32>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
// --- Precomputed reels -------------------------------------------------------
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = precomputed_reels)]
|
||||
pub struct InsertablePrecomputedReel {
|
||||
pub span: String,
|
||||
pub library_key: String,
|
||||
pub cache_key: String,
|
||||
pub output_path: String,
|
||||
pub title: String,
|
||||
pub media_count: i32,
|
||||
pub render_version: i32,
|
||||
pub tz_offset_minutes: i32,
|
||||
pub voice: Option<String>,
|
||||
pub generated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Queryable, Clone, Debug)]
|
||||
pub struct PrecomputedReel {
|
||||
pub id: i32,
|
||||
pub span: String,
|
||||
pub library_key: String,
|
||||
pub cache_key: String,
|
||||
pub output_path: String,
|
||||
pub title: String,
|
||||
pub media_count: i32,
|
||||
pub render_version: i32,
|
||||
pub tz_offset_minutes: i32,
|
||||
pub voice: Option<String>,
|
||||
pub generated_at: i64,
|
||||
}
|
||||
|
||||
// --- User AI preferences (Section E) ----------------------------------------
|
||||
|
||||
#[derive(Queryable, Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
#[diesel(table_name = user_ai_prefs)]
|
||||
pub struct UserAiPrefs {
|
||||
pub id: i32,
|
||||
pub voice: Option<String>,
|
||||
pub tz_offset_minutes: Option<i32>,
|
||||
pub library: Option<String>,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
#[diesel(table_name = user_ai_prefs)]
|
||||
pub struct UpsertUserAiPrefs {
|
||||
pub voice: Option<String>,
|
||||
pub tz_offset_minutes: Option<i32>,
|
||||
pub library: Option<String>,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ impl PersonaDao for SqlitePersonaDao {
|
||||
.load::<Persona>(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_persona(
|
||||
@@ -138,7 +138,7 @@ impl PersonaDao for SqlitePersonaDao {
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn create_persona(
|
||||
@@ -178,7 +178,7 @@ impl PersonaDao for SqlitePersonaDao {
|
||||
.first::<Persona>(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn update_persona(
|
||||
@@ -241,7 +241,7 @@ impl PersonaDao for SqlitePersonaDao {
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn delete_persona(
|
||||
@@ -258,7 +258,7 @@ impl PersonaDao for SqlitePersonaDao {
|
||||
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
|
||||
Ok(n > 0)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn bulk_import(
|
||||
@@ -294,7 +294,7 @@ impl PersonaDao for SqlitePersonaDao {
|
||||
}
|
||||
Ok(inserted)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use std::ops::DerefMut;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::database::models::{InsertablePrecomputedReel, PrecomputedReel};
|
||||
use crate::database::schema;
|
||||
use crate::database::{DbError, DbErrorKind, connect};
|
||||
use crate::otel::trace_db_call;
|
||||
|
||||
/// Ledger for precomputed memory reels. The nightly agentic job writes a
|
||||
/// row after each successful render; the `GET /reels/precomputed` handler
|
||||
/// reads it to gate on freshness and serve the cached MP4.
|
||||
pub trait PrecomputedReelDao: Sync + Send {
|
||||
/// Insert a precomputed reel row. Returns the new row's id.
|
||||
/// Written by the nightly agentic job (Section D).
|
||||
#[allow(dead_code)]
|
||||
fn record_reel(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
row: &InsertablePrecomputedReel,
|
||||
) -> Result<i32, DbError>;
|
||||
|
||||
/// Find the latest precomputed reel for the given (span, library_key).
|
||||
fn latest_for(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
span: &str,
|
||||
library_key: &str,
|
||||
) -> Result<Option<PrecomputedReel>, DbError>;
|
||||
|
||||
/// Return true when a fresh precomputed reel exists for the given
|
||||
/// (span, library_key, render_version) that was generated at or after
|
||||
/// `min_generated_at`. Used as a fast existence gate before falling
|
||||
/// back to `latest_for` (avoids a second query path).
|
||||
fn exists_fresh(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
span: &str,
|
||||
library_key: &str,
|
||||
render_version: i32,
|
||||
min_generated_at: i64,
|
||||
) -> Result<bool, DbError>;
|
||||
|
||||
/// Delete all but the newest `keep` rows for (span, library_key), returning
|
||||
/// the deleted rows so the caller can unlink their output files. Used by the
|
||||
/// nightly job to retire superseded reels (e.g. yesterday's daily).
|
||||
#[allow(dead_code)]
|
||||
fn prune_superseded(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
span: &str,
|
||||
library_key: &str,
|
||||
keep: usize,
|
||||
) -> Result<Vec<PrecomputedReel>, DbError>;
|
||||
|
||||
/// Every cache_key currently in the ledger. Used by the on-disk cache sweep
|
||||
/// to protect files a ledger row still points at.
|
||||
#[allow(dead_code)]
|
||||
fn all_cache_keys(&mut self, context: &opentelemetry::Context) -> Result<Vec<String>, DbError>;
|
||||
}
|
||||
|
||||
pub struct SqlitePrecomputedReelDao {
|
||||
connection: Arc<Mutex<SqliteConnection>>,
|
||||
}
|
||||
|
||||
impl Default for SqlitePrecomputedReelDao {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SqlitePrecomputedReelDao {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connection: Arc::new(Mutex::new(connect())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
|
||||
Self { connection: conn }
|
||||
}
|
||||
}
|
||||
|
||||
impl PrecomputedReelDao for SqlitePrecomputedReelDao {
|
||||
fn record_reel(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
row: &InsertablePrecomputedReel,
|
||||
) -> Result<i32, DbError> {
|
||||
trace_db_call(context, "insert", "record_reel", |_span| {
|
||||
use schema::precomputed_reels::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock PrecomputedReelDao");
|
||||
|
||||
diesel::insert_into(dsl::precomputed_reels)
|
||||
.values(row)
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to insert reel: {}", e))?;
|
||||
|
||||
dsl::precomputed_reels
|
||||
.order(dsl::id.desc())
|
||||
.select(dsl::id)
|
||||
.first::<i32>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get reel id: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
}
|
||||
|
||||
fn latest_for(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
span: &str,
|
||||
library_key: &str,
|
||||
) -> Result<Option<PrecomputedReel>, DbError> {
|
||||
trace_db_call(context, "query", "latest_for", |_span| {
|
||||
use schema::precomputed_reels::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock PrecomputedReelDao");
|
||||
|
||||
dsl::precomputed_reels
|
||||
.filter(dsl::span.eq(span))
|
||||
.filter(dsl::library_key.eq(library_key))
|
||||
.order(dsl::generated_at.desc())
|
||||
.first::<PrecomputedReel>(connection.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get latest reel: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn exists_fresh(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
span: &str,
|
||||
library_key: &str,
|
||||
render_version: i32,
|
||||
min_generated_at: i64,
|
||||
) -> Result<bool, DbError> {
|
||||
trace_db_call(context, "query", "exists_fresh", |_span| {
|
||||
use schema::precomputed_reels::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock PrecomputedReelDao");
|
||||
|
||||
let count: i64 = dsl::precomputed_reels
|
||||
.filter(dsl::span.eq(span))
|
||||
.filter(dsl::library_key.eq(library_key))
|
||||
.filter(dsl::render_version.eq(render_version))
|
||||
.filter(dsl::generated_at.ge(min_generated_at))
|
||||
.count()
|
||||
.get_result(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to check fresh reel: {}", e))?;
|
||||
|
||||
Ok(count > 0)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn prune_superseded(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
span: &str,
|
||||
library_key: &str,
|
||||
keep: usize,
|
||||
) -> Result<Vec<PrecomputedReel>, DbError> {
|
||||
trace_db_call(context, "delete", "prune_superseded", |_span| {
|
||||
use schema::precomputed_reels::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock PrecomputedReelDao");
|
||||
|
||||
// Newest first; everything past `keep` is superseded. The table
|
||||
// holds at most a handful of rows per (span, library), so loading
|
||||
// and slicing in Rust is cheaper than a correlated subquery.
|
||||
let mut rows: Vec<PrecomputedReel> = dsl::precomputed_reels
|
||||
.filter(dsl::span.eq(span))
|
||||
.filter(dsl::library_key.eq(library_key))
|
||||
.order(dsl::generated_at.desc())
|
||||
.load::<PrecomputedReel>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load reels for prune: {}", e))?;
|
||||
|
||||
let stale = rows.split_off(rows.len().min(keep));
|
||||
if !stale.is_empty() {
|
||||
let ids: Vec<i32> = stale.iter().map(|r| r.id).collect();
|
||||
diesel::delete(dsl::precomputed_reels.filter(dsl::id.eq_any(ids)))
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to delete superseded reels: {}", e))?;
|
||||
}
|
||||
Ok(stale)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
}
|
||||
|
||||
fn all_cache_keys(&mut self, context: &opentelemetry::Context) -> Result<Vec<String>, DbError> {
|
||||
trace_db_call(context, "query", "all_cache_keys", |_span| {
|
||||
use schema::precomputed_reels::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock PrecomputedReelDao");
|
||||
|
||||
dsl::precomputed_reels
|
||||
.select(dsl::cache_key)
|
||||
.load::<String>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load cache keys: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use diesel::Connection;
|
||||
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
|
||||
|
||||
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
||||
|
||||
fn setup_dao() -> SqlitePrecomputedReelDao {
|
||||
let mut conn = SqliteConnection::establish(":memory:")
|
||||
.expect("Unable to create in-memory db connection");
|
||||
conn.run_pending_migrations(DB_MIGRATIONS)
|
||||
.expect("Failure running DB migrations");
|
||||
SqlitePrecomputedReelDao::from_connection(Arc::new(Mutex::new(conn)))
|
||||
}
|
||||
|
||||
fn ctx() -> opentelemetry::Context {
|
||||
opentelemetry::Context::new()
|
||||
}
|
||||
|
||||
fn sample_row() -> InsertablePrecomputedReel {
|
||||
InsertablePrecomputedReel {
|
||||
span: "day".to_string(),
|
||||
library_key: "1".to_string(),
|
||||
cache_key: "abc123".to_string(),
|
||||
output_path: "/tmp/reel.mp4".to_string(),
|
||||
title: "Test Reel".to_string(),
|
||||
media_count: 10,
|
||||
render_version: 1,
|
||||
tz_offset_minutes: 0,
|
||||
voice: Some("default".to_string()),
|
||||
generated_at: 1_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_reel_inserts_and_returns_id() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
let row = sample_row();
|
||||
|
||||
let id = dao.record_reel(&ctx, &row).unwrap();
|
||||
assert!(id > 0, "should return a positive id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_reel_returns_increasing_ids() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
let row = sample_row();
|
||||
|
||||
let id1 = dao.record_reel(&ctx, &row).unwrap();
|
||||
let id2 = dao.record_reel(&ctx, &row).unwrap();
|
||||
assert!(id2 > id1, "each insert should get a higher id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_for_returns_latest() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let row1 = InsertablePrecomputedReel {
|
||||
generated_at: 1_000_000,
|
||||
..sample_row()
|
||||
};
|
||||
let row2 = InsertablePrecomputedReel {
|
||||
generated_at: 2_000_000,
|
||||
..sample_row()
|
||||
};
|
||||
|
||||
dao.record_reel(&ctx, &row1).unwrap();
|
||||
dao.record_reel(&ctx, &row2).unwrap();
|
||||
|
||||
let latest = dao.latest_for(&ctx, "day", "1").unwrap().unwrap();
|
||||
assert_eq!(latest.generated_at, 2_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_for_scoped_by_span_and_library() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let day_row = InsertablePrecomputedReel {
|
||||
span: "day".to_string(),
|
||||
library_key: "1".to_string(),
|
||||
generated_at: 1_000_000,
|
||||
..sample_row()
|
||||
};
|
||||
let week_row = InsertablePrecomputedReel {
|
||||
span: "week".to_string(),
|
||||
library_key: "1".to_string(),
|
||||
generated_at: 2_000_000,
|
||||
..sample_row()
|
||||
};
|
||||
|
||||
dao.record_reel(&ctx, &day_row).unwrap();
|
||||
dao.record_reel(&ctx, &week_row).unwrap();
|
||||
|
||||
let day_latest = dao.latest_for(&ctx, "day", "1").unwrap().unwrap();
|
||||
assert_eq!(day_latest.span, "day");
|
||||
|
||||
let week_latest = dao.latest_for(&ctx, "week", "1").unwrap().unwrap();
|
||||
assert_eq!(week_latest.span, "week");
|
||||
|
||||
// Different library returns None
|
||||
let missing = dao.latest_for(&ctx, "day", "99").unwrap();
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_for_returns_none_when_no_rows() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let result = dao.latest_for(&ctx, "day", "1").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exists_fresh_returns_true_when_present() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
dao.record_reel(&ctx, &sample_row()).unwrap();
|
||||
|
||||
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 900_000).unwrap();
|
||||
assert!(exists, "should find the row we just inserted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exists_fresh_returns_false_when_missing() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 900_000).unwrap();
|
||||
assert!(!exists, "should not find anything in empty table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exists_fresh_respects_min_generated_at() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
dao.record_reel(&ctx, &sample_row()).unwrap();
|
||||
|
||||
// Below the threshold — should exist
|
||||
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 500_000).unwrap();
|
||||
assert!(exists);
|
||||
|
||||
// Above the threshold — should not exist
|
||||
let exists = dao.exists_fresh(&ctx, "day", "1", 1, 2_000_000).unwrap();
|
||||
assert!(!exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exists_fresh_respects_render_version() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
|
||||
let row_v1 = InsertablePrecomputedReel {
|
||||
render_version: 1,
|
||||
..sample_row()
|
||||
};
|
||||
dao.record_reel(&ctx, &row_v1).unwrap();
|
||||
|
||||
assert!(dao.exists_fresh(&ctx, "day", "1", 1, 900_000).unwrap());
|
||||
assert!(!dao.exists_fresh(&ctx, "day", "1", 2, 900_000).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_superseded_keeps_newest_and_returns_deleted() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
// Three day/lib1 reels at increasing timestamps, plus an unrelated one.
|
||||
for (i, key) in ["k1", "k2", "k3"].iter().enumerate() {
|
||||
dao.record_reel(
|
||||
&ctx,
|
||||
&InsertablePrecomputedReel {
|
||||
cache_key: key.to_string(),
|
||||
generated_at: 1_000_000 + i as i64 * 1000,
|
||||
..sample_row()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let other = InsertablePrecomputedReel {
|
||||
library_key: "2".to_string(),
|
||||
cache_key: "other".to_string(),
|
||||
..sample_row()
|
||||
};
|
||||
dao.record_reel(&ctx, &other).unwrap();
|
||||
|
||||
// Keep the newest 2 of (day, "1"); k1 (oldest) is superseded.
|
||||
let deleted = dao.prune_superseded(&ctx, "day", "1", 2).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert_eq!(deleted[0].cache_key, "k1");
|
||||
|
||||
// The newest 2 survive; the other-library row is untouched.
|
||||
let keys = dao.all_cache_keys(&ctx).unwrap();
|
||||
assert_eq!(keys.len(), 3);
|
||||
assert!(keys.contains(&"k2".to_string()));
|
||||
assert!(keys.contains(&"k3".to_string()));
|
||||
assert!(keys.contains(&"other".to_string()));
|
||||
assert!(!keys.contains(&"k1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_superseded_noop_when_within_keep() {
|
||||
let mut dao = setup_dao();
|
||||
let ctx = ctx();
|
||||
dao.record_reel(&ctx, &sample_row()).unwrap();
|
||||
let deleted = dao.prune_superseded(&ctx, "day", "1", 2).unwrap();
|
||||
assert!(deleted.is_empty());
|
||||
assert_eq!(dao.all_cache_keys(&ctx).unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ impl PreviewDao for SqlitePreviewDao {
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn update_status(
|
||||
@@ -126,7 +126,7 @@ impl PreviewDao for SqlitePreviewDao {
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn get_preview(
|
||||
@@ -148,7 +148,7 @@ impl PreviewDao for SqlitePreviewDao {
|
||||
Err(e) => Err(anyhow::anyhow!("Query error: {}", e)),
|
||||
}
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_previews_batch(
|
||||
@@ -170,7 +170,7 @@ impl PreviewDao for SqlitePreviewDao {
|
||||
.load::<VideoPreviewClip>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_by_status(
|
||||
@@ -188,7 +188,7 @@ impl PreviewDao for SqlitePreviewDao {
|
||||
.load::<VideoPreviewClip>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-22
@@ -57,28 +57,30 @@ impl ReconcileStats {
|
||||
/// watcher tick. Errors are logged but never propagated; reconciliation
|
||||
/// is best-effort and a transient DB hiccup must not stall the watcher.
|
||||
pub fn run(conn: &mut SqliteConnection) -> ReconcileStats {
|
||||
let stats = ReconcileStats {
|
||||
tagged_photo_hashes_filled: match backfill_tagged_photo_hashes(conn) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
warn!("reconcile: tagged_photo hash backfill failed: {:?}", e);
|
||||
0
|
||||
}
|
||||
},
|
||||
photo_insights_hashes_filled: match backfill_photo_insights_hashes(conn) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
warn!("reconcile: photo_insights hash backfill failed: {:?}", e);
|
||||
0
|
||||
}
|
||||
},
|
||||
photo_insights_demoted: match collapse_insight_currents(conn) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
warn!("reconcile: photo_insights scalar merge failed: {:?}", e);
|
||||
0
|
||||
}
|
||||
},
|
||||
let mut stats = ReconcileStats::default();
|
||||
|
||||
stats.tagged_photo_hashes_filled = match backfill_tagged_photo_hashes(conn) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
warn!("reconcile: tagged_photo hash backfill failed: {:?}", e);
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
stats.photo_insights_hashes_filled = match backfill_photo_insights_hashes(conn) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
warn!("reconcile: photo_insights hash backfill failed: {:?}", e);
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
stats.photo_insights_demoted = match collapse_insight_currents(conn) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
warn!("reconcile: photo_insights scalar merge failed: {:?}", e);
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
if stats.changed() {
|
||||
|
||||
@@ -138,8 +138,6 @@ diesel::table! {
|
||||
date_taken_source -> Nullable<Text>,
|
||||
original_date_taken -> Nullable<BigInt>,
|
||||
original_date_taken_source -> Nullable<Text>,
|
||||
clip_embedding -> Nullable<Binary>,
|
||||
clip_model_version -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,15 +214,6 @@ diesel::table! {
|
||||
backend -> Text,
|
||||
fewshot_source_ids -> Nullable<Text>,
|
||||
content_hash -> Nullable<Text>,
|
||||
num_ctx -> Nullable<Integer>,
|
||||
temperature -> Nullable<Float>,
|
||||
top_p -> Nullable<Float>,
|
||||
top_k -> Nullable<Integer>,
|
||||
min_p -> Nullable<Float>,
|
||||
system_prompt -> Nullable<Text>,
|
||||
persona_id -> Nullable<Text>,
|
||||
prompt_eval_count -> Nullable<Integer>,
|
||||
eval_count -> Nullable<Integer>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,16 +255,6 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
user_ai_prefs (id) {
|
||||
id -> Integer,
|
||||
voice -> Nullable<Text>,
|
||||
tz_offset_minutes -> Nullable<Integer>,
|
||||
library -> Nullable<Text>,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
video_preview_clips (id) {
|
||||
id -> Integer,
|
||||
@@ -290,43 +269,12 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
insight_generation_jobs (id) {
|
||||
id -> Integer,
|
||||
library_id -> Integer,
|
||||
file_path -> Text,
|
||||
generation_type -> Text,
|
||||
status -> Text,
|
||||
started_at -> BigInt,
|
||||
completed_at -> Nullable<BigInt>,
|
||||
result_insight_id -> Nullable<Integer>,
|
||||
error_message -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
precomputed_reels (id) {
|
||||
id -> Integer,
|
||||
span -> Text,
|
||||
library_key -> Text,
|
||||
cache_key -> Text,
|
||||
output_path -> Text,
|
||||
title -> Text,
|
||||
media_count -> Integer,
|
||||
render_version -> Integer,
|
||||
tz_offset_minutes -> Integer,
|
||||
voice -> Nullable<Text>,
|
||||
generated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(entity_facts -> photo_insights (source_insight_id));
|
||||
diesel::joinable!(entity_photo_links -> entities (entity_id));
|
||||
diesel::joinable!(entity_photo_links -> libraries (library_id));
|
||||
diesel::joinable!(face_detections -> libraries (library_id));
|
||||
diesel::joinable!(face_detections -> persons (person_id));
|
||||
diesel::joinable!(image_exif -> libraries (library_id));
|
||||
diesel::joinable!(insight_generation_jobs -> libraries (library_id));
|
||||
diesel::joinable!(personas -> users (user_id));
|
||||
diesel::joinable!(persons -> entities (entity_id));
|
||||
diesel::joinable!(photo_insights -> libraries (library_id));
|
||||
@@ -342,17 +290,14 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
face_detections,
|
||||
favorites,
|
||||
image_exif,
|
||||
insight_generation_jobs,
|
||||
libraries,
|
||||
location_history,
|
||||
personas,
|
||||
persons,
|
||||
photo_insights,
|
||||
precomputed_reels,
|
||||
search_history,
|
||||
tagged_photo,
|
||||
tags,
|
||||
user_ai_prefs,
|
||||
users,
|
||||
video_preview_clips,
|
||||
);
|
||||
|
||||
+17
-20
@@ -189,11 +189,10 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
|
||||
.expect("Unable to get SearchHistoryDao");
|
||||
|
||||
// Validate embedding dimensions (REQUIRED for searches)
|
||||
if search.embedding.len() != crate::ai::embedding_dim() {
|
||||
if search.embedding.len() != 768 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid embedding dimensions: {} (expected {})",
|
||||
search.embedding.len(),
|
||||
crate::ai::embedding_dim()
|
||||
"Invalid embedding dimensions: {} (expected 768)",
|
||||
search.embedding.len()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -228,7 +227,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
|
||||
source_file: search.source_file,
|
||||
})
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn store_searches_batch(
|
||||
@@ -246,7 +245,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
|
||||
conn.transaction::<_, anyhow::Error, _>(|conn| {
|
||||
for search in searches {
|
||||
// Validate embedding (REQUIRED)
|
||||
if search.embedding.len() != crate::ai::embedding_dim() {
|
||||
if search.embedding.len() != 768 {
|
||||
log::warn!(
|
||||
"Skipping search with invalid embedding dimensions: {}",
|
||||
search.embedding.len()
|
||||
@@ -284,7 +283,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
|
||||
|
||||
Ok(inserted)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn find_searches_in_range(
|
||||
@@ -311,7 +310,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
|
||||
.map(|rows| rows.into_iter().map(|r| r.to_search_record()).collect())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {:?}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn find_similar_searches(
|
||||
@@ -326,11 +325,10 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
|
||||
.lock()
|
||||
.expect("Unable to get SearchHistoryDao");
|
||||
|
||||
if query_embedding.len() != crate::ai::embedding_dim() {
|
||||
if query_embedding.len() != 768 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid query embedding dimensions: {} (expected {})",
|
||||
query_embedding.len(),
|
||||
crate::ai::embedding_dim()
|
||||
"Invalid query embedding dimensions: {} (expected 768)",
|
||||
query_embedding.len()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -374,7 +372,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
|
||||
.map(|(_, search)| search)
|
||||
.collect())
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn find_relevant_searches_hybrid(
|
||||
@@ -408,11 +406,10 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
|
||||
|
||||
// Step 2: If query embedding provided, rank by semantic similarity
|
||||
if let Some(query_emb) = query_embedding {
|
||||
if query_emb.len() != crate::ai::embedding_dim() {
|
||||
if query_emb.len() != 768 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid query embedding dimensions: {} (expected {})",
|
||||
query_emb.len(),
|
||||
crate::ai::embedding_dim()
|
||||
"Invalid query embedding dimensions: {} (expected 768)",
|
||||
query_emb.len()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -462,7 +459,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
|
||||
.collect())
|
||||
}
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn search_exists(
|
||||
@@ -493,7 +490,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
|
||||
|
||||
Ok(result.count > 0)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_search_count(&mut self, context: &opentelemetry::Context) -> Result<i64, DbError> {
|
||||
@@ -516,6 +513,6 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
|
||||
|
||||
Ok(result.count)
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use std::ops::DerefMut;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::database::models::{UpsertUserAiPrefs, UserAiPrefs};
|
||||
use crate::database::schema;
|
||||
use crate::database::{DbError, DbErrorKind, connect};
|
||||
use crate::otel::trace_db_call;
|
||||
|
||||
/// Generic single-row table that passively mirrors the latest client AI
|
||||
/// request parameters (voice, timezone, library). Read by the nightly
|
||||
/// pre-generation scheduler (Section D) to pick up user preferences.
|
||||
pub trait UserAiPrefsDao: Sync + Send {
|
||||
/// Read the single row; `None` when it hasn't been populated yet.
|
||||
fn get_prefs(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
) -> Result<Option<UserAiPrefs>, DbError>;
|
||||
|
||||
/// Upsert the single row (id is always 1).
|
||||
#[allow(dead_code)]
|
||||
fn upsert_prefs(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
prefs: &UpsertUserAiPrefs,
|
||||
) -> Result<(), DbError>;
|
||||
}
|
||||
|
||||
pub struct SqliteUserAiPrefsDao {
|
||||
connection: Arc<Mutex<SqliteConnection>>,
|
||||
}
|
||||
|
||||
impl Default for SqliteUserAiPrefsDao {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SqliteUserAiPrefsDao {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connection: Arc::new(Mutex::new(connect())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
|
||||
Self { connection: conn }
|
||||
}
|
||||
}
|
||||
|
||||
impl UserAiPrefsDao for SqliteUserAiPrefsDao {
|
||||
fn get_prefs(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
) -> Result<Option<UserAiPrefs>, DbError> {
|
||||
trace_db_call(context, "query", "get_prefs", |_span| {
|
||||
use schema::user_ai_prefs::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock UserAiPrefsDao");
|
||||
|
||||
dsl::user_ai_prefs
|
||||
.first::<UserAiPrefs>(connection.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get prefs: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn upsert_prefs(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
prefs: &UpsertUserAiPrefs,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "upsert", "upsert_prefs", |_span| {
|
||||
use schema::user_ai_prefs::dsl;
|
||||
|
||||
let mut connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.expect("Unable to lock UserAiPrefsDao");
|
||||
|
||||
// Single-row table (id=1): one atomic upsert. The explicit id=1
|
||||
// makes the conflict target deterministic so the second call
|
||||
// updates in place rather than tripping the CHECK(id=1) constraint,
|
||||
// and real insert errors surface instead of being swallowed into a
|
||||
// separate update branch. The columns are set explicitly (rather
|
||||
// than via AsChangeset) so a None field overwrites to NULL — the
|
||||
// row mirrors the latest request exactly, not a merge of past ones.
|
||||
diesel::insert_into(dsl::user_ai_prefs)
|
||||
.values((dsl::id.eq(1), prefs))
|
||||
.on_conflict(dsl::id)
|
||||
.do_update()
|
||||
.set((
|
||||
dsl::voice.eq(&prefs.voice),
|
||||
dsl::tz_offset_minutes.eq(&prefs.tz_offset_minutes),
|
||||
dsl::library.eq(&prefs.library),
|
||||
dsl::updated_at.eq(&prefs.updated_at),
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to upsert prefs: {}", e))?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use diesel::Connection;
|
||||
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
|
||||
|
||||
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
||||
|
||||
fn setup_dao() -> SqliteUserAiPrefsDao {
|
||||
let mut conn = SqliteConnection::establish(":memory:")
|
||||
.expect("Unable to create in-memory db connection");
|
||||
conn.run_pending_migrations(DB_MIGRATIONS)
|
||||
.expect("Failure running DB migrations");
|
||||
SqliteUserAiPrefsDao::from_connection(Arc::new(Mutex::new(conn)))
|
||||
}
|
||||
|
||||
fn ctx() -> opentelemetry::Context {
|
||||
opentelemetry::Context::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_prefs_returns_none_when_empty() {
|
||||
let mut dao = setup_dao();
|
||||
let result = dao.get_prefs(&ctx()).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_prefs_inserts_row() {
|
||||
let mut dao = setup_dao();
|
||||
let now = 1_700_000_000i64;
|
||||
let prefs = UpsertUserAiPrefs {
|
||||
voice: Some("grandma".to_string()),
|
||||
tz_offset_minutes: Some(-480),
|
||||
library: Some("1".to_string()),
|
||||
updated_at: now,
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs).unwrap();
|
||||
|
||||
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
|
||||
assert_eq!(row.id, 1);
|
||||
assert_eq!(row.voice, Some("grandma".to_string()));
|
||||
assert_eq!(row.tz_offset_minutes, Some(-480));
|
||||
assert_eq!(row.library, Some("1".to_string()));
|
||||
assert_eq!(row.updated_at, now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_prefs_replaces_existing() {
|
||||
let mut dao = setup_dao();
|
||||
let now1 = 1_700_000_000i64;
|
||||
let now2 = 1_800_000_000i64;
|
||||
|
||||
let prefs1 = UpsertUserAiPrefs {
|
||||
voice: Some("grandma".to_string()),
|
||||
tz_offset_minutes: Some(-480),
|
||||
library: Some("1".to_string()),
|
||||
updated_at: now1,
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs1).unwrap();
|
||||
|
||||
let prefs2 = UpsertUserAiPrefs {
|
||||
voice: Some("dad".to_string()),
|
||||
tz_offset_minutes: Some(-300),
|
||||
library: None,
|
||||
updated_at: now2,
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs2).unwrap();
|
||||
|
||||
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
|
||||
assert_eq!(row.voice, Some("dad".to_string()));
|
||||
assert_eq!(row.tz_offset_minutes, Some(-300));
|
||||
assert!(row.library.is_none());
|
||||
assert_eq!(row.updated_at, now2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_partial_fields() {
|
||||
let mut dao = setup_dao();
|
||||
let now = 1_700_000_000i64;
|
||||
|
||||
let prefs = UpsertUserAiPrefs {
|
||||
voice: None,
|
||||
tz_offset_minutes: Some(-480),
|
||||
library: None,
|
||||
updated_at: now,
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs).unwrap();
|
||||
|
||||
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
|
||||
assert_eq!(row.tz_offset_minutes, Some(-480));
|
||||
assert!(row.voice.is_none());
|
||||
assert!(row.library.is_none());
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -234,7 +234,7 @@ async fn list_exact_handler(
|
||||
let span = global_tracer().start_with_context("duplicates.list_exact", &context);
|
||||
let span_context = opentelemetry::Context::current_with_span(span);
|
||||
|
||||
let library_id = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||
let library_id = libraries::resolve_library_param(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|l| l.id);
|
||||
@@ -265,7 +265,7 @@ async fn list_perceptual_handler(
|
||||
let span = global_tracer().start_with_context("duplicates.list_perceptual", &context);
|
||||
let span_context = opentelemetry::Context::current_with_span(span);
|
||||
|
||||
let library_id = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||
let library_id = libraries::resolve_library_param(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|l| l.id);
|
||||
@@ -449,7 +449,7 @@ async fn list_folder_pairs_handler(
|
||||
let span = global_tracer().start_with_context("duplicates.list_folder_pairs", &context);
|
||||
let span_context = opentelemetry::Context::current_with_span(span);
|
||||
|
||||
let library_id = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||
let library_id = libraries::resolve_library_param(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|l| l.id);
|
||||
|
||||
+22
-46
@@ -1024,14 +1024,9 @@ impl FaceDao for SqliteFaceDao {
|
||||
if let Some(lib) = library_id {
|
||||
q = q.filter(face_detections::library_id.eq(lib));
|
||||
}
|
||||
q.select(
|
||||
#[allow(deprecated)]
|
||||
{
|
||||
diesel::dsl::count_distinct(face_detections::content_hash)
|
||||
},
|
||||
)
|
||||
.first(conn.deref_mut())
|
||||
.with_context(|| "stats: scanned")?
|
||||
q.select(diesel::dsl::count_distinct(face_detections::content_hash))
|
||||
.first(conn.deref_mut())
|
||||
.with_context(|| "stats: scanned")?
|
||||
};
|
||||
let with_faces: i64 = {
|
||||
let mut q = face_detections::table
|
||||
@@ -1040,14 +1035,9 @@ impl FaceDao for SqliteFaceDao {
|
||||
if let Some(lib) = library_id {
|
||||
q = q.filter(face_detections::library_id.eq(lib));
|
||||
}
|
||||
q.select(
|
||||
#[allow(deprecated)]
|
||||
{
|
||||
diesel::dsl::count_distinct(face_detections::content_hash)
|
||||
},
|
||||
)
|
||||
.first(conn.deref_mut())
|
||||
.with_context(|| "stats: with_faces")?
|
||||
q.select(diesel::dsl::count_distinct(face_detections::content_hash))
|
||||
.first(conn.deref_mut())
|
||||
.with_context(|| "stats: with_faces")?
|
||||
};
|
||||
let no_faces: i64 = {
|
||||
let mut q = face_detections::table
|
||||
@@ -1056,14 +1046,9 @@ impl FaceDao for SqliteFaceDao {
|
||||
if let Some(lib) = library_id {
|
||||
q = q.filter(face_detections::library_id.eq(lib));
|
||||
}
|
||||
q.select(
|
||||
#[allow(deprecated)]
|
||||
{
|
||||
diesel::dsl::count_distinct(face_detections::content_hash)
|
||||
},
|
||||
)
|
||||
.first(conn.deref_mut())
|
||||
.with_context(|| "stats: no_faces")?
|
||||
q.select(diesel::dsl::count_distinct(face_detections::content_hash))
|
||||
.first(conn.deref_mut())
|
||||
.with_context(|| "stats: no_faces")?
|
||||
};
|
||||
let failed: i64 = {
|
||||
let mut q = face_detections::table
|
||||
@@ -1072,14 +1057,9 @@ impl FaceDao for SqliteFaceDao {
|
||||
if let Some(lib) = library_id {
|
||||
q = q.filter(face_detections::library_id.eq(lib));
|
||||
}
|
||||
q.select(
|
||||
#[allow(deprecated)]
|
||||
{
|
||||
diesel::dsl::count_distinct(face_detections::content_hash)
|
||||
},
|
||||
)
|
||||
.first(conn.deref_mut())
|
||||
.with_context(|| "stats: failed")?
|
||||
q.select(diesel::dsl::count_distinct(face_detections::content_hash))
|
||||
.first(conn.deref_mut())
|
||||
.with_context(|| "stats: failed")?
|
||||
};
|
||||
// Image-extension filter mirrors `list_unscanned_candidates` so
|
||||
// SCANNED can actually reach 100%: videos sit in `image_exif` but
|
||||
@@ -1755,7 +1735,7 @@ async fn stats_handler<D: FaceDao>(
|
||||
let span = global_tracer().start_with_context("faces.stats", &context);
|
||||
let span_context = opentelemetry::Context::current_with_span(span);
|
||||
|
||||
let library_id = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||
let library_id = libraries::resolve_library_param(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|l| l.id);
|
||||
@@ -1782,12 +1762,11 @@ async fn list_faces_handler<D: FaceDao>(
|
||||
let normalized_path = normalize_path(&query.path);
|
||||
// resolve_library_param returns Option<&Library>; clone so the result
|
||||
// is owned (matching the primary_library fallback's type).
|
||||
let library: Library =
|
||||
libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| app_state.primary_library().clone());
|
||||
let library: Library = libraries::resolve_library_param(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| app_state.primary_library().clone());
|
||||
|
||||
let mut dao = face_dao.lock().expect("face dao lock");
|
||||
let hash = match dao.resolve_content_hash(&span_context, library.id, &normalized_path) {
|
||||
@@ -1871,7 +1850,7 @@ async fn create_face_handler<D: FaceDao>(
|
||||
}
|
||||
|
||||
let normalized_path = normalize_path(&body.path);
|
||||
let library: Library = match libraries::resolve_library_param_state(
|
||||
let library: Library = match libraries::resolve_library_param(
|
||||
&app_state,
|
||||
body.library.as_ref().map(|i| i.to_string()).as_deref(),
|
||||
) {
|
||||
@@ -2139,10 +2118,7 @@ async fn update_face_handler<D: FaceDao>(
|
||||
// the short context string we surface in the response body —
|
||||
// SQLITE_BUSY here usually means another DAO's writer held the
|
||||
// lock past `busy_timeout` (5s), which is invisible in `{}`.
|
||||
warn!(
|
||||
"PATCH /image/faces/{}: 500 — update_face failed: {:#}",
|
||||
id, e
|
||||
);
|
||||
warn!("PATCH /image/faces/{}: 500 — update_face failed: {:#}", id, e);
|
||||
return HttpResponse::InternalServerError().body(e.to_string());
|
||||
}
|
||||
};
|
||||
@@ -2193,7 +2169,7 @@ async fn list_persons_handler<D: FaceDao>(
|
||||
let span = global_tracer().start_with_context("persons.list", &context);
|
||||
let span_context = opentelemetry::Context::current_with_span(span);
|
||||
|
||||
let library_id = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||
let library_id = libraries::resolve_library_param(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|l| l.id);
|
||||
@@ -2346,7 +2322,7 @@ async fn person_faces_handler<D: FaceDao>(
|
||||
let context = extract_context_from_request(&request);
|
||||
let span = global_tracer().start_with_context("persons.faces", &context);
|
||||
let span_context = opentelemetry::Context::current_with_span(span);
|
||||
let library_id = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||
let library_id = libraries::resolve_library_param(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|l| l.id);
|
||||
|
||||
@@ -53,7 +53,6 @@ pub fn walk_library_files(base_path: &Path, excluded_dirs: &[String]) -> Vec<Dir
|
||||
/// used by the watcher's quick-scan tick to skip the long tail. Files
|
||||
/// whose metadata can't be read are kept; the caller's batch EXIF lookup
|
||||
/// dedups against existing rows.
|
||||
#[allow(dead_code)]
|
||||
pub fn enumerate_indexable_files(
|
||||
base_path: &Path,
|
||||
excluded_dirs: &[String],
|
||||
|
||||
@@ -22,42 +22,8 @@ pub fn needs_ffmpeg_thumbnail(path: &Path) -> bool {
|
||||
/// Supported video file extensions
|
||||
pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "avi", "mkv"];
|
||||
|
||||
/// Audio file extensions accepted as voice-clone references (TTS). Mirrors
|
||||
/// the formats Chatterbox can decode (wav/mp3/flac/m4a/aac/ogg).
|
||||
pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "mp3", "flac", "m4a", "aac", "ogg", "oga", "opus"];
|
||||
|
||||
/// Filenames that are filesystem metadata, not real media — exact
|
||||
/// basename match. Extend if a new platform sidecar appears (Windows
|
||||
/// Thumbs.db / desktop.ini live here too if those libraries land).
|
||||
const METADATA_FILENAMES: &[&str] = &[".DS_Store"];
|
||||
|
||||
/// True if the basename is a filesystem metadata sidecar that should be
|
||||
/// invisible to every media predicate.
|
||||
///
|
||||
/// macOS writes `._<name>` AppleDouble companions when copying to
|
||||
/// non-HFS volumes — each holds the extended attributes of `<name>`,
|
||||
/// NOT a copy of the bytes. Same extension as the real file, so a
|
||||
/// pure-extension match treats `._photo.jpg` as a JPEG, ships it to
|
||||
/// the decoder, and accumulates failed rows: face_detections
|
||||
/// `status='failed'`, clip_embedding `status='failed'`, plus a
|
||||
/// pointless `image_exif` row whose `content_hash` will be the hash
|
||||
/// of the metadata blob. The downstream noise (failed-row counts that
|
||||
/// never go to zero, 422 bursts to Apollo, evictor timer reset by
|
||||
/// those 422s) is the visible damage. `.DS_Store` is the per-directory
|
||||
/// version (Finder view state) — no extension, but cheap to guard
|
||||
/// here too in case some future predicate matches by content type.
|
||||
pub fn is_filesystem_metadata(path: &Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
name.starts_with("._") || METADATA_FILENAMES.contains(&name)
|
||||
}
|
||||
|
||||
/// Check if a path has an image extension
|
||||
pub fn is_image_file(path: &Path) -> bool {
|
||||
if is_filesystem_metadata(path) {
|
||||
return false;
|
||||
}
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
let ext_lower = ext.to_lowercase();
|
||||
IMAGE_EXTENSIONS.contains(&ext_lower.as_str())
|
||||
@@ -68,9 +34,6 @@ pub fn is_image_file(path: &Path) -> bool {
|
||||
|
||||
/// Check if a path has a video extension
|
||||
pub fn is_video_file(path: &Path) -> bool {
|
||||
if is_filesystem_metadata(path) {
|
||||
return false;
|
||||
}
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
let ext_lower = ext.to_lowercase();
|
||||
VIDEO_EXTENSIONS.contains(&ext_lower.as_str())
|
||||
@@ -79,19 +42,6 @@ pub fn is_video_file(path: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a path has an audio extension (voice-clone references)
|
||||
pub fn is_audio_file(path: &Path) -> bool {
|
||||
if is_filesystem_metadata(path) {
|
||||
return false;
|
||||
}
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
let ext_lower = ext.to_lowercase();
|
||||
AUDIO_EXTENSIONS.contains(&ext_lower.as_str())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a path has a supported media extension (image or video)
|
||||
pub fn is_media_file(path: &Path) -> bool {
|
||||
is_image_file(path) || is_video_file(path)
|
||||
@@ -148,46 +98,4 @@ mod tests {
|
||||
assert!(!is_media_file(Path::new("document.txt")));
|
||||
assert!(!is_media_file(Path::new("no_extension")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apple_double_excluded_from_media() {
|
||||
// The bug-of-record: ImageApi was shipping macOS AppleDouble
|
||||
// sidecars to Apollo's CLIP/face decoders, accumulating failed
|
||||
// rows and pinning Apollo's eviction timer with the 422 burst.
|
||||
// Predicate-level guard means every downstream walker
|
||||
// (face_watch, backfill, clip_watch, watcher) inherits the fix
|
||||
// without touching their filters.
|
||||
assert!(!is_image_file(Path::new("._photo.jpg")));
|
||||
assert!(!is_image_file(Path::new("dir/._photo.JPG")));
|
||||
assert!(!is_image_file(Path::new("a/b/._DSC_2182-S.jpg")));
|
||||
assert!(!is_video_file(Path::new("._video.mp4")));
|
||||
assert!(!is_media_file(Path::new("._photo.png")));
|
||||
// A real file that merely starts with "_" (no leading dot) is
|
||||
// not AppleDouble — must NOT be filtered.
|
||||
assert!(is_image_file(Path::new("_photo.jpg")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ds_store_excluded() {
|
||||
// Finder per-directory metadata. No image extension so
|
||||
// is_image_file would already say false; the guard makes the
|
||||
// predicate's *reason* explicit and covers a hypothetical
|
||||
// future caller matching by basename.
|
||||
assert!(!is_image_file(Path::new(".DS_Store")));
|
||||
assert!(!is_video_file(Path::new(".DS_Store")));
|
||||
assert!(!is_media_file(Path::new("some/dir/.DS_Store")));
|
||||
assert!(is_filesystem_metadata(Path::new(".DS_Store")));
|
||||
assert!(is_filesystem_metadata(Path::new("dir/.DS_Store")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dotfiles_other_than_apple_double_are_unaffected() {
|
||||
// We deliberately scope to `._*` + the exact .DS_Store name —
|
||||
// not all dotfiles — because a user could plausibly name a
|
||||
// cover image `.cover.jpg` and we shouldn't silently drop it.
|
||||
// If that turns out to be wrong, broaden here; for now,
|
||||
// narrow + explicit > broad + surprising.
|
||||
assert!(is_image_file(Path::new(".cover.jpg")));
|
||||
assert!(!is_filesystem_metadata(Path::new(".cover.jpg")));
|
||||
}
|
||||
}
|
||||
|
||||
+9
-59
@@ -275,14 +275,14 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
// Resolve the optional library filter. Unknown values return 400. A
|
||||
// `None` result means "union across all libraries" and downstream
|
||||
// walks iterate every configured library root.
|
||||
let library =
|
||||
match crate::libraries::resolve_library_param_state(&app_state, req.library.as_deref()) {
|
||||
Ok(lib) => lib,
|
||||
Err(msg) => {
|
||||
log::warn!("Rejecting /photos request: {}", msg);
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
let library = match crate::libraries::resolve_library_param(&app_state, req.library.as_deref())
|
||||
{
|
||||
Ok(lib) => lib,
|
||||
Err(msg) => {
|
||||
log::warn!("Rejecting /photos request: {}", msg);
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
|
||||
let span_context = opentelemetry::Context::current_with_span(span);
|
||||
|
||||
@@ -1238,7 +1238,7 @@ pub async fn list_exif_summary(
|
||||
// Resolve the library filter up front so a bad id/name 400s before we
|
||||
// ever take the DAO mutex. None == union across all libraries.
|
||||
let library_filter =
|
||||
match crate::libraries::resolve_library_param_state(&app_state, req.library.as_deref()) {
|
||||
match crate::libraries::resolve_library_param(&app_state, req.library.as_deref()) {
|
||||
Ok(lib) => lib.map(|l| l.id),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
@@ -1511,8 +1511,6 @@ mod tests {
|
||||
date_taken_source,
|
||||
original_date_taken: None,
|
||||
original_date_taken_source: None,
|
||||
clip_embedding: None,
|
||||
clip_model_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1552,8 +1550,6 @@ mod tests {
|
||||
date_taken_source: data.date_taken_source.clone(),
|
||||
original_date_taken: None,
|
||||
original_date_taken_source: None,
|
||||
clip_embedding: None,
|
||||
clip_model_version: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1600,8 +1596,6 @@ mod tests {
|
||||
date_taken_source: data.date_taken_source.clone(),
|
||||
original_date_taken: None,
|
||||
original_date_taken_source: None,
|
||||
clip_embedding: None,
|
||||
clip_model_version: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1695,21 +1689,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_distinct_content_hashes(
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
) -> Result<Vec<String>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn list_paths_and_hashes_for_library(
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
_library_id: i32,
|
||||
) -> Result<Vec<(String, Option<String>)>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn get_rows_needing_date_backfill(
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
@@ -1938,35 +1917,6 @@ mod tests {
|
||||
) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_clip_unencoded_candidates(
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
_library_id: i32,
|
||||
_limit: i64,
|
||||
) -> Result<Vec<(String, String)>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn backfill_clip_embedding(
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
_library_id: i32,
|
||||
_rel_path: &str,
|
||||
_embedding: &[u8],
|
||||
_model_version: &str,
|
||||
) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_clip_index(
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
_library_ids: &[i32],
|
||||
_model_version: Option<&str>,
|
||||
) -> Result<Vec<(String, Vec<u8>)>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
mod api {
|
||||
|
||||
-172
@@ -1,5 +1,4 @@
|
||||
/// Geographic calculation utilities for GPS-based search
|
||||
use serde::Deserialize;
|
||||
use std::f64;
|
||||
|
||||
/// Calculate distance between two GPS coordinates using the Haversine formula.
|
||||
@@ -62,140 +61,6 @@ pub fn gps_bounding_box(lat: f64, lon: f64, radius_km: f64) -> (f64, f64, f64, f
|
||||
)
|
||||
}
|
||||
|
||||
/// A place resolved from a free-text query via forward geocoding.
|
||||
///
|
||||
/// The filter pipeline searches a *circle* (`gps_lat`/`gps_lon`/
|
||||
/// `gps_radius_km`), but a place can be anything from a single address to
|
||||
/// a whole country. We collapse Nominatim's bounding box into the smallest
|
||||
/// circle that circumscribes it (see [`bbox_to_circle`]) so "Portland" and
|
||||
/// "Italy" both map onto the existing circle filter without a schema change.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GeoPlace {
|
||||
/// Nominatim's canonical name for the match (e.g. "Italia").
|
||||
pub display_name: String,
|
||||
/// Centroid latitude in decimal degrees.
|
||||
pub lat: f64,
|
||||
/// Centroid longitude in decimal degrees.
|
||||
pub lon: f64,
|
||||
/// Radius (km) of a circle centred on the centroid that covers the
|
||||
/// matched area. Floored to [`MIN_PLACE_RADIUS_KM`] so a point result
|
||||
/// (whose bounding box is microscopic) still yields a usable circle.
|
||||
pub radius_km: f64,
|
||||
}
|
||||
|
||||
/// Floor for a geocoded place's radius. Point results (a street address)
|
||||
/// come back with a near-zero bounding box; without a floor the circle
|
||||
/// filter would match nothing.
|
||||
pub const MIN_PLACE_RADIUS_KM: f64 = 0.5;
|
||||
|
||||
/// Collapse a bounding box into the centroid + circumscribing radius.
|
||||
///
|
||||
/// Input is Nominatim's `boundingbox` order: `(south_lat, north_lat,
|
||||
/// west_lon, east_lon)`. The radius is the *largest* great-circle distance
|
||||
/// from the centroid to any of the four corners, so the resulting circle
|
||||
/// fully covers the box. (The corners aren't equidistant on a sphere —
|
||||
/// longitude lines converge toward the poles, so the equator-facing edge's
|
||||
/// corners are farthest; taking the max guarantees coverage in either
|
||||
/// hemisphere.)
|
||||
///
|
||||
/// Pure and exact (no flooring) so it can be unit-tested directly; callers
|
||||
/// apply [`MIN_PLACE_RADIUS_KM`] when turning the result into a filter.
|
||||
pub fn bbox_to_circle(south: f64, north: f64, west: f64, east: f64) -> (f64, f64, f64) {
|
||||
let center_lat = (south + north) / 2.0;
|
||||
let center_lon = (west + east) / 2.0;
|
||||
let radius_km = [(south, west), (south, east), (north, west), (north, east)]
|
||||
.iter()
|
||||
.map(|(clat, clon)| haversine_distance(center_lat, center_lon, *clat, *clon))
|
||||
.fold(0.0_f64, f64::max);
|
||||
(center_lat, center_lon, radius_km)
|
||||
}
|
||||
|
||||
/// Raw Nominatim `/search` result. `lat`/`lon` arrive as strings and
|
||||
/// `boundingbox` as a 4-element string array `[south, north, west, east]`.
|
||||
#[derive(Deserialize)]
|
||||
struct NominatimSearchResult {
|
||||
lat: String,
|
||||
lon: String,
|
||||
display_name: String,
|
||||
boundingbox: Option<[String; 4]>,
|
||||
}
|
||||
|
||||
/// Forward-geocode a free-text place name to a [`GeoPlace`] via the public
|
||||
/// OpenStreetMap Nominatim `/search` endpoint.
|
||||
///
|
||||
/// Mirrors `InsightGenerator::reverse_geocode`'s error posture: any network,
|
||||
/// HTTP, or parse failure returns `None` rather than propagating, so a flaky
|
||||
/// geocoder degrades the query to "no location filter" instead of failing it.
|
||||
///
|
||||
/// Nominatim's usage policy requires a `User-Agent` and rate-limits to ~1
|
||||
/// request/second; callers doing this interactively should cache results.
|
||||
pub async fn forward_geocode(query: &str) -> Option<GeoPlace> {
|
||||
let q = query.trim();
|
||||
if q.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = match client
|
||||
.get("https://nominatim.openstreetmap.org/search")
|
||||
.query(&[("format", "json"), ("limit", "1"), ("q", q)])
|
||||
.header("User-Agent", "ImageAPI/1.0") // Nominatim requires User-Agent
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log::warn!("Forward geocoding network error for {q:?}: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
log::warn!(
|
||||
"Forward geocoding HTTP error for {q:?}: {}",
|
||||
response.status()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let results: Vec<NominatimSearchResult> = match response.json().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::warn!("Forward geocoding JSON parse error for {q:?}: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let top = results.into_iter().next()?;
|
||||
let lat: f64 = top.lat.parse().ok()?;
|
||||
let lon: f64 = top.lon.parse().ok()?;
|
||||
|
||||
// Prefer the bounding box (handles large places); fall back to a
|
||||
// point + floor radius when Nominatim omits it.
|
||||
let (center_lat, center_lon, radius_km) = match &top.boundingbox {
|
||||
Some([s, n, w, e]) => match (s.parse(), n.parse(), w.parse(), e.parse()) {
|
||||
(Ok(s), Ok(n), Ok(w), Ok(e)) => bbox_to_circle(s, n, w, e),
|
||||
_ => (lat, lon, 0.0),
|
||||
},
|
||||
None => (lat, lon, 0.0),
|
||||
};
|
||||
|
||||
let place = GeoPlace {
|
||||
display_name: top.display_name,
|
||||
lat: center_lat,
|
||||
lon: center_lon,
|
||||
radius_km: radius_km.max(MIN_PLACE_RADIUS_KM),
|
||||
};
|
||||
log::info!(
|
||||
"Forward geocoded {q:?} -> {} ({:.4}, {:.4}, r={:.1}km)",
|
||||
place.display_name,
|
||||
place.lat,
|
||||
place.lon,
|
||||
place.radius_km
|
||||
);
|
||||
Some(place)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -253,41 +118,4 @@ mod tests {
|
||||
distance
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bbox_to_circle_centroid() {
|
||||
// Symmetric box around (10, 20): centroid should land dead centre.
|
||||
let (lat, lon, radius) = bbox_to_circle(9.0, 11.0, 19.0, 21.0);
|
||||
assert!((lat - 10.0).abs() < 1e-9, "centroid lat, got {lat}");
|
||||
assert!((lon - 20.0).abs() < 1e-9, "centroid lon, got {lon}");
|
||||
assert!(radius > 0.0, "radius should be positive, got {radius}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bbox_to_circle_covers_corner() {
|
||||
// The radius must reach every corner of the box. Verify the
|
||||
// centroid-to-corner distance equals the returned radius for all
|
||||
// four corners (they're symmetric, so all equal).
|
||||
let (south, north, west, east) = (40.0, 42.0, -74.0, -72.0);
|
||||
let (lat, lon, radius) = bbox_to_circle(south, north, west, east);
|
||||
for (clat, clon) in [(south, west), (south, east), (north, west), (north, east)] {
|
||||
let d = haversine_distance(lat, lon, clat, clon);
|
||||
assert!(
|
||||
d <= radius + 1e-6,
|
||||
"corner ({clat},{clon}) at {d}km should be within radius {radius}km"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bbox_to_circle_country_vs_city_scale() {
|
||||
// A country-sized box yields a far larger radius than a city-sized
|
||||
// one — confirming the bbox approach scales with place size.
|
||||
let (_, _, country) = bbox_to_circle(35.5, 47.1, 6.6, 18.5); // ~Italy
|
||||
let (_, _, city) = bbox_to_circle(45.4, 45.6, -122.8, -122.5); // ~Portland
|
||||
assert!(
|
||||
country > city * 10.0,
|
||||
"country radius {country}km should dwarf city radius {city}km"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-223
@@ -53,7 +53,7 @@ pub async fn get_image(
|
||||
|
||||
// Resolve library from query param; default to primary so clients that
|
||||
// don't yet send `library=` continue to work.
|
||||
let library = match libraries::resolve_library_param_state(&app_state, req.library.as_deref()) {
|
||||
let library = match libraries::resolve_library_param(&app_state, req.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
@@ -82,209 +82,6 @@ pub async fn get_image(
|
||||
|
||||
if let Some((library, path)) = resolved {
|
||||
let image_size = req.size.unwrap_or(PhotoSize::Full);
|
||||
|
||||
// `size=large|xlarge` is only meaningful for stills — there's no
|
||||
// useful "resized video preview" tier. Videos fall back to the
|
||||
// existing thumb pipeline (which already handles gif/static
|
||||
// selection). `mut` so preview branches can downgrade to `Full`
|
||||
// after a generation failure.
|
||||
let mut image_size = if (image_size == PhotoSize::Large || image_size == PhotoSize::XLarge)
|
||||
&& file_types::is_video_file(&path)
|
||||
{
|
||||
PhotoSize::Thumb
|
||||
} else {
|
||||
image_size
|
||||
};
|
||||
|
||||
if image_size == PhotoSize::Large {
|
||||
let relative_path = path
|
||||
.strip_prefix(&library.root_path)
|
||||
.expect("Error stripping library root prefix from large preview");
|
||||
let relative_path_str = relative_path.to_string_lossy().replace('\\', "/");
|
||||
let thumbs = Path::new(&app_state.thumbnail_path);
|
||||
let large_dir = thumbs.join("_large");
|
||||
|
||||
// Lookup chain mirrors the Thumb branch — hash-keyed first so
|
||||
// multi-library deployments share derivative bytes across
|
||||
// libraries, then library-scoped legacy as the fallback for
|
||||
// rows that aren't hashed yet.
|
||||
let hash_large_path: Option<PathBuf> = {
|
||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
match dao.get_exif(&context, &relative_path_str) {
|
||||
Ok(Some(row)) => row
|
||||
.content_hash
|
||||
.as_deref()
|
||||
.map(|h| content_hash::large_preview_path(thumbs, h)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let scoped_legacy_large_path =
|
||||
content_hash::library_scoped_legacy_path(&large_dir, library.id, relative_path);
|
||||
|
||||
let existing = hash_large_path
|
||||
.as_ref()
|
||||
.filter(|p| p.exists())
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
if scoped_legacy_large_path.exists() {
|
||||
Some(scoped_legacy_large_path.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(found) = existing
|
||||
&& let Ok(file) = NamedFile::open(&found)
|
||||
{
|
||||
span.set_status(Status::Ok);
|
||||
return file
|
||||
.use_etag(true)
|
||||
.use_last_modified(true)
|
||||
.prefer_utf8(true)
|
||||
.into_response(&request);
|
||||
}
|
||||
|
||||
// Cache miss — generate. Resize + JPEG-encode can take 100–500ms
|
||||
// for a 24MP source (longer for RAW), so run on the blocking pool
|
||||
// to keep the actix worker free. Prefer the hash-keyed
|
||||
// destination when a hash is known so the result is reusable
|
||||
// across libraries that hold the same bytes.
|
||||
let dest = hash_large_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| scoped_legacy_large_path.clone());
|
||||
let src = path.clone();
|
||||
let dest_for_block = dest.clone();
|
||||
let generated = web::block(move || {
|
||||
if let Some(parent) = dest_for_block.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
// Write to a sibling tempfile then atomically rename so a
|
||||
// concurrent reader never observes a half-written JPEG.
|
||||
let tmp = dest_for_block.with_extension("jpg.tmp");
|
||||
crate::thumbnails::generate_large_preview(&src, &tmp)?;
|
||||
std::fs::rename(&tmp, &dest_for_block)?;
|
||||
Ok::<(), std::io::Error>(())
|
||||
})
|
||||
.await;
|
||||
|
||||
match generated {
|
||||
Ok(Ok(())) => {
|
||||
if let Ok(file) = NamedFile::open(&dest) {
|
||||
span.set_status(Status::Ok);
|
||||
return file
|
||||
.use_etag(true)
|
||||
.use_last_modified(true)
|
||||
.prefer_utf8(true)
|
||||
.into_response(&request);
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!(
|
||||
"Large preview generation failed for {:?}: {} — falling back to original",
|
||||
path, e
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Large preview blocking-pool error for {:?}: {} — falling back to original",
|
||||
path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fall through to the Full branch below so the caller gets
|
||||
// *something* useful (the original bytes — or the RAW
|
||||
// embedded preview, which is what the Full branch returns for
|
||||
// unrenderable RAW containers) instead of a 404.
|
||||
image_size = PhotoSize::Full;
|
||||
}
|
||||
|
||||
if image_size == PhotoSize::XLarge {
|
||||
let relative_path = path
|
||||
.strip_prefix(&library.root_path)
|
||||
.expect("Error stripping library root prefix from xlarge preview");
|
||||
let relative_path_str = relative_path.to_string_lossy().replace('\\', "/");
|
||||
let thumbs = Path::new(&app_state.thumbnail_path);
|
||||
let xlarge_dir = thumbs.join("_xlarge");
|
||||
|
||||
let hash_xlarge_path: Option<PathBuf> = {
|
||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
match dao.get_exif(&context, &relative_path_str) {
|
||||
Ok(Some(row)) => row
|
||||
.content_hash
|
||||
.as_deref()
|
||||
.map(|h| content_hash::xlarge_preview_path(thumbs, h)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let scoped_legacy_xlarge_path =
|
||||
content_hash::library_scoped_legacy_path(&xlarge_dir, library.id, relative_path);
|
||||
|
||||
let existing = hash_xlarge_path
|
||||
.as_ref()
|
||||
.filter(|p| p.exists())
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
if scoped_legacy_xlarge_path.exists() {
|
||||
Some(scoped_legacy_xlarge_path.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(found) = existing
|
||||
&& let Ok(file) = NamedFile::open(&found)
|
||||
{
|
||||
span.set_status(Status::Ok);
|
||||
return file
|
||||
.use_etag(true)
|
||||
.use_last_modified(true)
|
||||
.prefer_utf8(true)
|
||||
.into_response(&request);
|
||||
}
|
||||
|
||||
let dest = hash_xlarge_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| scoped_legacy_xlarge_path.clone());
|
||||
let src = path.clone();
|
||||
let dest_for_block = dest.clone();
|
||||
let generated = web::block(move || {
|
||||
if let Some(parent) = dest_for_block.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = dest_for_block.with_extension("jpg.tmp");
|
||||
crate::thumbnails::generate_xlarge_preview(&src, &tmp)?;
|
||||
std::fs::rename(&tmp, &dest_for_block)?;
|
||||
Ok::<(), std::io::Error>(())
|
||||
})
|
||||
.await;
|
||||
|
||||
match generated {
|
||||
Ok(Ok(())) => {
|
||||
if let Ok(file) = NamedFile::open(&dest) {
|
||||
span.set_status(Status::Ok);
|
||||
return file
|
||||
.use_etag(true)
|
||||
.use_last_modified(true)
|
||||
.prefer_utf8(true)
|
||||
.into_response(&request);
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!(
|
||||
"XLarge preview generation failed for {:?}: {} — falling back to original",
|
||||
path, e
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"XLarge preview blocking-pool error for {:?}: {} — falling back to original",
|
||||
path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
image_size = PhotoSize::Full;
|
||||
}
|
||||
|
||||
if image_size == PhotoSize::Thumb {
|
||||
let relative_path = path
|
||||
.strip_prefix(&library.root_path)
|
||||
@@ -386,15 +183,14 @@ pub async fn get_image(
|
||||
// review JPEG, ~1–2 MP). Falls through to NamedFile if no preview is
|
||||
// available, which preserves the historical behavior for callers
|
||||
// that genuinely want the original bytes.
|
||||
if image_size == PhotoSize::Full
|
||||
&& exif::is_tiff_raw(&path)
|
||||
&& let Some(preview) = exif::extract_embedded_jpeg_preview(&path)
|
||||
{
|
||||
span.set_status(Status::Ok);
|
||||
return HttpResponse::Ok()
|
||||
.content_type("image/jpeg")
|
||||
.insert_header(("Cache-Control", "public, max-age=3600"))
|
||||
.body(preview);
|
||||
if image_size == PhotoSize::Full && exif::is_tiff_raw(&path) {
|
||||
if let Some(preview) = exif::extract_embedded_jpeg_preview(&path) {
|
||||
span.set_status(Status::Ok);
|
||||
return HttpResponse::Ok()
|
||||
.content_type("image/jpeg")
|
||||
.insert_header(("Cache-Control", "public, max-age=3600"))
|
||||
.body(preview);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(file) = NamedFile::open(&path) {
|
||||
@@ -492,7 +288,7 @@ pub async fn get_file_metadata(
|
||||
let span_context =
|
||||
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
|
||||
|
||||
let library = libraries::resolve_library_param_state(&app_state, path.library.as_deref())
|
||||
let library = libraries::resolve_library_param(&app_state, path.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
@@ -580,7 +376,7 @@ pub async fn set_image_gps(
|
||||
let span_context =
|
||||
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
|
||||
|
||||
let library = libraries::resolve_library_param_state(&app_state, body.library.as_deref())
|
||||
let library = libraries::resolve_library_param(&app_state, body.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
@@ -746,7 +542,7 @@ pub async fn get_full_exif(
|
||||
let context = extract_context_from_request(&request);
|
||||
let mut span = tracer.start_with_context("get_full_exif", &context);
|
||||
|
||||
let library = libraries::resolve_library_param_state(&app_state, path.library.as_deref())
|
||||
let library = libraries::resolve_library_param(&app_state, path.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
@@ -888,8 +684,7 @@ pub async fn set_image_date(
|
||||
let span_context =
|
||||
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
|
||||
|
||||
let library = match libraries::resolve_library_param_state(&app_state, body.library.as_deref())
|
||||
{
|
||||
let library = match libraries::resolve_library_param(&app_state, body.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
@@ -911,7 +706,7 @@ pub async fn set_image_date(
|
||||
Ok(row) => {
|
||||
span.set_status(Status::Ok);
|
||||
HttpResponse::Ok().json(build_metadata_response_for_date_mutation(
|
||||
library,
|
||||
&library,
|
||||
&normalized_path,
|
||||
row,
|
||||
))
|
||||
@@ -942,8 +737,7 @@ pub async fn clear_image_date(
|
||||
let span_context =
|
||||
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
|
||||
|
||||
let library = match libraries::resolve_library_param_state(&app_state, body.library.as_deref())
|
||||
{
|
||||
let library = match libraries::resolve_library_param(&app_state, body.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
@@ -963,7 +757,7 @@ pub async fn clear_image_date(
|
||||
Ok(row) => {
|
||||
span.set_status(Status::Ok);
|
||||
HttpResponse::Ok().json(build_metadata_response_for_date_mutation(
|
||||
library,
|
||||
&library,
|
||||
&normalized_path,
|
||||
row,
|
||||
))
|
||||
@@ -1003,7 +797,7 @@ pub async fn upload_image(
|
||||
// Resolve the optional library selector. Absent → primary library
|
||||
// (backwards-compatible with clients that don't yet send `library=`).
|
||||
let target_library =
|
||||
match libraries::resolve_library_param_state(&app_state, query.library.as_deref()) {
|
||||
match libraries::resolve_library_param(&app_state, query.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
|
||||
+119
-334
@@ -11,313 +11,190 @@ use actix_web::{
|
||||
web::{self, Data},
|
||||
};
|
||||
use log::{debug, error, info, warn};
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry::trace::{Span, Status, Tracer};
|
||||
use serde::Serialize;
|
||||
use opentelemetry::{KeyValue, global};
|
||||
|
||||
use crate::content_hash;
|
||||
use crate::data::{
|
||||
Claims, PreviewClipRequest, PreviewStatusItem, PreviewStatusRequest, PreviewStatusResponse,
|
||||
ThumbnailRequest,
|
||||
};
|
||||
use crate::database::{ExifDao, PreviewDao};
|
||||
use crate::database::PreviewDao;
|
||||
use crate::files::is_valid_full_path;
|
||||
use crate::libraries;
|
||||
use crate::otel::{extract_context_from_request, global_tracer};
|
||||
use crate::state::AppState;
|
||||
use crate::video::actors::{
|
||||
GeneratePreviewClipMessage, QueueVideosMessage, VideoToQueue, probe_video_stream_meta,
|
||||
};
|
||||
use crate::video::hls_paths;
|
||||
|
||||
/// Response body for `POST /video/generate`. Clients consume
|
||||
/// `playlist_url` (hash-keyed, stable across libraries and renames)
|
||||
/// and poll for readiness via the URL itself.
|
||||
#[derive(Serialize, Debug)]
|
||||
struct GenerateVideoResponse {
|
||||
/// Hash-keyed URL to the HLS playlist. Resolves to
|
||||
/// `$VIDEO_PATH/<shard>/<hash>/playlist.m3u8` server-side. Relative
|
||||
/// segment refs inside the playlist resolve correctly because the
|
||||
/// browser appends to this URL's path.
|
||||
playlist_url: String,
|
||||
/// blake3 content hash of the source video. Stable per byte content,
|
||||
/// so duplicate uploads / archive ingests share one set of HLS
|
||||
/// output.
|
||||
content_hash: String,
|
||||
/// `true` iff the playlist file is already on disk. `false` means a
|
||||
/// transcode was queued; clients should retry the URL after a short
|
||||
/// delay (or rely on HLS.js's own retry policy).
|
||||
ready: bool,
|
||||
/// Source-video frame rate in Hz, probed via ffprobe. `None` when the
|
||||
/// probe failed or ffprobe couldn't parse either rate field — clients
|
||||
/// fall back to their own default (typically 30) for frame stepping.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
frame_rate: Option<f32>,
|
||||
}
|
||||
use crate::video::actors::{GeneratePreviewClipMessage, ProcessMessage, create_playlist};
|
||||
|
||||
#[post("/video/generate")]
|
||||
pub async fn generate_video(
|
||||
_claims: Claims,
|
||||
request: HttpRequest,
|
||||
app_state: Data<AppState>,
|
||||
exif_dao: Data<std::sync::Mutex<Box<dyn ExifDao>>>,
|
||||
body: web::Json<ThumbnailRequest>,
|
||||
) -> impl Responder {
|
||||
let tracer = global_tracer();
|
||||
|
||||
let context = extract_context_from_request(&request);
|
||||
let mut span = tracer.start_with_context("generate_video", &context);
|
||||
|
||||
let preferred_library =
|
||||
libraries::resolve_library_param_state(&app_state, body.library.as_deref())
|
||||
let filename = PathBuf::from(&body.path);
|
||||
|
||||
if let Some(name) = filename.file_name() {
|
||||
let filename = name.to_str().expect("Filename should convert to string");
|
||||
// KNOWN ISSUE (multi-library): playlist filename is the basename
|
||||
// alone, so two source files with the same basename — whether in
|
||||
// different libraries or different subdirs of one library —
|
||||
// overwrite each other's playlists while ffmpeg runs. The
|
||||
// hash-keyed `content_hash::hls_dir` is the long-term answer
|
||||
// (see CLAUDE.md "Multi-library data model"); rewiring the
|
||||
// actor pipeline to use it is out of scope for this branch.
|
||||
// The orphan-cleanup job above already walks every library so
|
||||
// it doesn't false-delete archive playlists.
|
||||
let playlist = format!("{}/{}.m3u8", app_state.video_path, filename);
|
||||
|
||||
let library = libraries::resolve_library_param(&app_state, body.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
|
||||
// Try the resolved library first, then fall back to any other library
|
||||
// that actually contains the file — handles union-mode requests where
|
||||
// the mobile client passes no library but the file lives in a
|
||||
// non-primary library. Track which library won so the DB lookup is
|
||||
// scoped correctly.
|
||||
let resolved = is_valid_full_path(&preferred_library.root_path, &body.path, false)
|
||||
.filter(|p| p.exists())
|
||||
.map(|p| (preferred_library.id, preferred_library.root_path.clone(), p))
|
||||
.or_else(|| {
|
||||
app_state.libraries.iter().find_map(|lib| {
|
||||
if lib.id == preferred_library.id {
|
||||
return None;
|
||||
}
|
||||
is_valid_full_path(&lib.root_path, &body.path, false)
|
||||
.filter(|p| p.exists())
|
||||
.map(|p| (lib.id, lib.root_path.clone(), p))
|
||||
})
|
||||
});
|
||||
// Try the resolved library first, then fall back to any other library
|
||||
// that actually contains the file — handles union-mode requests where
|
||||
// the mobile client passes no library but the file lives in a
|
||||
// non-primary library.
|
||||
let resolved = is_valid_full_path(&library.root_path, &body.path, false)
|
||||
.filter(|p| p.exists())
|
||||
.or_else(|| {
|
||||
app_state.libraries.iter().find_map(|lib| {
|
||||
if lib.id == library.id {
|
||||
return None;
|
||||
}
|
||||
is_valid_full_path(&lib.root_path, &body.path, false).filter(|p| p.exists())
|
||||
})
|
||||
});
|
||||
|
||||
let Some((resolved_library_id, resolved_root, full_path)) = resolved else {
|
||||
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
||||
return HttpResponse::BadRequest().finish();
|
||||
};
|
||||
|
||||
// Build the rel_path used to look up the row. Forward-slash normalized
|
||||
// so the lookup matches DB rows on Windows — see `rel_path_for_lookup`.
|
||||
let full_path_str = full_path.to_string_lossy().to_string();
|
||||
let rel_path = rel_path_for_lookup(&full_path_str, &resolved_root);
|
||||
|
||||
// DB lookup first. Cheap and avoids re-reading the file off disk for
|
||||
// already-ingested videos.
|
||||
let hash_from_db: Option<String> = {
|
||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
match dao.get_exif_batch(
|
||||
&context,
|
||||
Some(resolved_library_id),
|
||||
std::slice::from_ref(&rel_path),
|
||||
) {
|
||||
Ok(rows) => rows.into_iter().next().and_then(|r| r.content_hash),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"exif_dao.get_exif_batch failed for {} (lib {}): {:?}",
|
||||
rel_path, resolved_library_id, e
|
||||
if let Some(path) = resolved {
|
||||
if let Ok(child) = create_playlist(path.to_str().unwrap(), &playlist).await {
|
||||
span.add_event(
|
||||
"playlist_created".to_string(),
|
||||
vec![KeyValue::new("playlist-name", filename.to_string())],
|
||||
);
|
||||
None
|
||||
|
||||
span.set_status(Status::Ok);
|
||||
app_state.stream_manager.do_send(ProcessMessage(
|
||||
playlist.clone(),
|
||||
child,
|
||||
// opentelemetry::Context::new().with_span(span),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
||||
return HttpResponse::BadRequest().finish();
|
||||
}
|
||||
};
|
||||
|
||||
// Best-effort fallback: compute on-the-fly when the DB row hasn't
|
||||
// been written or is mid-backfill. Read-only — no library mutation.
|
||||
let content_hash_str = match hash_from_db {
|
||||
Some(h) => h,
|
||||
None => match content_hash::compute(&full_path) {
|
||||
Ok(id) => id.content_hash,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to compute content_hash for {}: {}",
|
||||
full_path.display(),
|
||||
e
|
||||
);
|
||||
span.set_status(Status::error(format!("hash compute failed: {}", e)));
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let video_dir = std::path::Path::new(&app_state.video_path);
|
||||
let playlist_path = hls_paths::playlist_for_hash(video_dir, &content_hash_str);
|
||||
let sentinel_path = hls_paths::sentinel_for_hash(video_dir, &content_hash_str);
|
||||
let ready = playlist_path.exists();
|
||||
|
||||
if !ready && !sentinel_path.exists() {
|
||||
// Kick off generation via the existing actor pipeline. Fire-and-
|
||||
// forget — the playlist appears at `playlist_path` once ffmpeg
|
||||
// + rename complete. The client polls the URL.
|
||||
info!(
|
||||
"/video/generate: queueing playlist for {} (hash={})",
|
||||
full_path.display(),
|
||||
&content_hash_str[..content_hash_str.len().min(16)]
|
||||
);
|
||||
app_state.playlist_manager.do_send(QueueVideosMessage {
|
||||
videos: vec![VideoToQueue {
|
||||
video_path: full_path.clone(),
|
||||
content_hash: content_hash_str.clone(),
|
||||
}],
|
||||
});
|
||||
span.add_event(
|
||||
"playlist_queued",
|
||||
vec![KeyValue::new("content_hash", content_hash_str.clone())],
|
||||
);
|
||||
} else if ready {
|
||||
span.add_event(
|
||||
"playlist_already_present",
|
||||
vec![KeyValue::new("content_hash", content_hash_str.clone())],
|
||||
);
|
||||
HttpResponse::Ok().json(playlist)
|
||||
} else {
|
||||
// Sentinel present — past transcode attempt failed. Return the
|
||||
// URL anyway (it'll 404 / 5xx at fetch time) so the client gets
|
||||
// a deterministic answer. Operator must delete the sentinel to
|
||||
// force a retry.
|
||||
warn!(
|
||||
"/video/generate: unsupported sentinel present for {} (hash={}); not re-queueing",
|
||||
full_path.display(),
|
||||
&content_hash_str[..content_hash_str.len().min(16)]
|
||||
);
|
||||
let message = format!("Unable to get file name: {:?}", filename);
|
||||
error!("{}", message);
|
||||
span.set_status(Status::error(message));
|
||||
|
||||
HttpResponse::BadRequest().finish()
|
||||
}
|
||||
|
||||
let playlist_url = format!(
|
||||
"/video/hls/{}/{}",
|
||||
content_hash_str,
|
||||
hls_paths::PLAYLIST_FILENAME
|
||||
);
|
||||
|
||||
// Probe the source for frame rate so the mobile scrubber can step at
|
||||
// the right interval. Cheap (~tens of ms) and only runs once per video
|
||||
// open. Probe failures degrade silently — clients have a fallback.
|
||||
let frame_rate = probe_video_stream_meta(&full_path.to_string_lossy())
|
||||
.await
|
||||
.frame_rate;
|
||||
|
||||
span.set_status(Status::Ok);
|
||||
HttpResponse::Ok().json(GenerateVideoResponse {
|
||||
playlist_url,
|
||||
content_hash: content_hash_str,
|
||||
ready,
|
||||
frame_rate,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve HLS playlist or segment files under the hash-keyed layout
|
||||
/// `$VIDEO_PATH/<shard>/<hash>/<file>`. The matched `{file}` must be
|
||||
/// either `playlist.m3u8` or a `segment_NNN.ts` style segment; any other
|
||||
/// shape is 400'd to defend against operators stashing other content in
|
||||
/// the hash dir.
|
||||
#[get("/video/hls/{hash}/{file}")]
|
||||
pub async fn stream_hls_file(
|
||||
#[get("/video/stream")]
|
||||
pub async fn stream_video(
|
||||
request: HttpRequest,
|
||||
_: Claims,
|
||||
path: web::Path<(String, String)>,
|
||||
path: web::Query<ThumbnailRequest>,
|
||||
app_state: Data<AppState>,
|
||||
) -> impl Responder {
|
||||
let tracer = global::tracer("image-server");
|
||||
let context = extract_context_from_request(&request);
|
||||
let mut span = tracer.start_with_context("stream_video", &context);
|
||||
|
||||
let playlist = &path.path;
|
||||
debug!("Playlist: {}", playlist);
|
||||
|
||||
// Only serve files under video_path (HLS playlists) or base_path (source videos)
|
||||
if playlist.starts_with(&app_state.video_path)
|
||||
|| is_valid_full_path(&app_state.base_path, playlist, false).is_some()
|
||||
{
|
||||
match NamedFile::open(playlist) {
|
||||
Ok(file) => {
|
||||
span.set_status(Status::Ok);
|
||||
file.into_response(&request)
|
||||
}
|
||||
_ => {
|
||||
span.set_status(Status::error(format!("playlist not found {}", playlist)));
|
||||
HttpResponse::NotFound().finish()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
span.set_status(Status::error(format!("playlist not valid {}", playlist)));
|
||||
HttpResponse::BadRequest().finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/video/{path}")]
|
||||
pub async fn get_video_part(
|
||||
request: HttpRequest,
|
||||
_: Claims,
|
||||
path: web::Path<ThumbnailRequest>,
|
||||
app_state: Data<AppState>,
|
||||
) -> impl Responder {
|
||||
let tracer = global_tracer();
|
||||
let context = extract_context_from_request(&request);
|
||||
let mut span = tracer.start_with_context("stream_hls_file", &context);
|
||||
let mut span = tracer.start_with_context("get_video_part", &context);
|
||||
|
||||
let (hash, file) = path.into_inner();
|
||||
if !is_valid_hash(&hash) {
|
||||
span.set_status(Status::error("invalid hash"));
|
||||
return HttpResponse::BadRequest().body("invalid hash");
|
||||
}
|
||||
if !is_allowed_hls_filename(&file) {
|
||||
span.set_status(Status::error("invalid file"));
|
||||
return HttpResponse::BadRequest().body("invalid file");
|
||||
}
|
||||
let part = &path.path;
|
||||
debug!("Video part: {}", part);
|
||||
|
||||
let shard = &hash[..2];
|
||||
let file_path = PathBuf::from(&app_state.video_path)
|
||||
.join(shard)
|
||||
.join(&hash)
|
||||
.join(&file);
|
||||
let mut file_part = PathBuf::new();
|
||||
file_part.push(app_state.video_path.clone());
|
||||
file_part.push(part);
|
||||
|
||||
// Path-traversal guard: canonicalize both sides and require the file
|
||||
// to live under `app_state.video_path`. `is_valid_hash` /
|
||||
// `is_allowed_hls_filename` already block dangerous strings, but
|
||||
// belt-and-suspenders here is cheap.
|
||||
// Guard against directory traversal attacks
|
||||
let canonical_base = match std::fs::canonicalize(&app_state.video_path) {
|
||||
Ok(p) => p,
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
error!("Failed to canonicalize VIDEO_PATH: {:?}", e);
|
||||
span.set_status(Status::error("VIDEO_PATH not canonicalisable"));
|
||||
error!("Failed to canonicalize video path: {:?}", e);
|
||||
span.set_status(Status::error("Invalid video path configuration"));
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
let canonical_file = match std::fs::canonicalize(&file_path) {
|
||||
Ok(p) => p,
|
||||
|
||||
let canonical_file = match std::fs::canonicalize(&file_part) {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
debug!("HLS file not found: {}", file_path.display());
|
||||
span.set_status(Status::error("not found"));
|
||||
warn!("Video part not found or invalid: {:?}", file_part);
|
||||
span.set_status(Status::error(format!("Video part not found '{}'", part)));
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure the resolved path is still within the video directory
|
||||
if !canonical_file.starts_with(&canonical_base) {
|
||||
warn!(
|
||||
"Path traversal attempt: {} resolved outside VIDEO_PATH",
|
||||
file_path.display()
|
||||
);
|
||||
span.set_status(Status::error("traversal"));
|
||||
warn!("Directory traversal attempt detected: {:?}", part);
|
||||
span.set_status(Status::error("Invalid video path"));
|
||||
return HttpResponse::Forbidden().finish();
|
||||
}
|
||||
|
||||
match NamedFile::open(&canonical_file) {
|
||||
Ok(f) => {
|
||||
Ok(file) => {
|
||||
span.set_status(Status::Ok);
|
||||
f.into_response(&request)
|
||||
file.into_response(&request)
|
||||
}
|
||||
Err(_) => {
|
||||
span.set_status(Status::error("not found"));
|
||||
_ => {
|
||||
error!("Video part not found: {:?}", file_part);
|
||||
span.set_status(Status::error(format!(
|
||||
"Video part not found '{}'",
|
||||
file_part.to_str().unwrap()
|
||||
)));
|
||||
HttpResponse::NotFound().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 64 lowercase-or-upper hex chars. Strict so we don't accept arbitrary
|
||||
/// strings that might canonicalize into trouble.
|
||||
fn is_valid_hash(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Compute the forward-slash `rel_path` used to look up a video's
|
||||
/// `image_exif` row, from its absolute path string and the library root.
|
||||
///
|
||||
/// 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\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.
|
||||
fn rel_path_for_lookup(full_path_str: &str, resolved_root: &str) -> String {
|
||||
full_path_str
|
||||
.strip_prefix(resolved_root)
|
||||
.unwrap_or(full_path_str)
|
||||
.trim_start_matches(['/', '\\'])
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
/// Allowed file names inside a hash dir. `playlist.m3u8` plus segment
|
||||
/// files matching the `segment_NNN.ts` template that `PlaylistGenerator`
|
||||
/// writes via `hls_paths::SEGMENT_TEMPLATE`. Anything else (including
|
||||
/// `.tmp`, `.unsupported`, dotfiles) returns 400 — these are internal
|
||||
/// artifacts the client should never request.
|
||||
fn is_allowed_hls_filename(name: &str) -> bool {
|
||||
if name == hls_paths::PLAYLIST_FILENAME {
|
||||
return true;
|
||||
}
|
||||
if let Some(rest) = name.strip_prefix("segment_")
|
||||
&& let Some(num) = rest.strip_suffix(".ts")
|
||||
&& !num.is_empty()
|
||||
&& num.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[get("/video/preview")]
|
||||
pub async fn get_video_preview(
|
||||
_claims: Claims,
|
||||
@@ -550,98 +427,6 @@ mod tests {
|
||||
use crate::testhelpers::TestPreviewDao;
|
||||
use actix_web::App;
|
||||
|
||||
#[test]
|
||||
fn is_valid_hash_requires_64_ascii_hex() {
|
||||
assert!(is_valid_hash(&"a".repeat(64)));
|
||||
assert!(is_valid_hash(&"F".repeat(64)));
|
||||
assert!(is_valid_hash(&format!("ab{}", "0".repeat(62))));
|
||||
|
||||
assert!(!is_valid_hash(&"a".repeat(63)));
|
||||
assert!(!is_valid_hash(&"a".repeat(65)));
|
||||
// Anything outside the hex alphabet — including '/', '.', '..' —
|
||||
// is rejected up front so the path-traversal canonicalisation
|
||||
// never has to defend the boundary alone.
|
||||
assert!(!is_valid_hash(&format!("/{}", "a".repeat(63))));
|
||||
assert!(!is_valid_hash(&format!("..{}", "a".repeat(62))));
|
||||
assert!(!is_valid_hash(&"g".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_allowed_hls_filename_accepts_only_playlist_and_segments() {
|
||||
assert!(is_allowed_hls_filename("playlist.m3u8"));
|
||||
assert!(is_allowed_hls_filename("segment_000.ts"));
|
||||
assert!(is_allowed_hls_filename("segment_999.ts"));
|
||||
assert!(is_allowed_hls_filename("segment_0.ts"));
|
||||
|
||||
// Internal artifacts the client should never request.
|
||||
assert!(!is_allowed_hls_filename("playlist.m3u8.tmp"));
|
||||
assert!(!is_allowed_hls_filename("playlist.unsupported"));
|
||||
// Traversal / path components — defence in depth alongside
|
||||
// the actix path matcher itself.
|
||||
assert!(!is_allowed_hls_filename(".."));
|
||||
assert!(!is_allowed_hls_filename("../etc/passwd"));
|
||||
assert!(!is_allowed_hls_filename("segment_abc.ts"));
|
||||
assert!(!is_allowed_hls_filename("segment_.ts"));
|
||||
assert!(!is_allowed_hls_filename(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rel_path_for_lookup_normalizes_windows_separators() {
|
||||
// Windows: backslash root + backslash full path. The stored row is
|
||||
// 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\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/Melissa/clip.mp4", "/media/pic"),
|
||||
"Melissa/clip.mp4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rel_path_for_lookup_file_at_root_has_no_separator() {
|
||||
// A file directly in the library root has no internal separator, so
|
||||
// the bug never manifested here — guard against a regression anyway.
|
||||
assert_eq!(
|
||||
rel_path_for_lookup(r"Z:\Media\pic\clip.mp4", r"Z:\Media\pic"),
|
||||
"clip.mp4"
|
||||
);
|
||||
assert_eq!(
|
||||
rel_path_for_lookup("/media/pic/clip.mp4", "/media/pic"),
|
||||
"clip.mp4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rel_path_for_lookup_strips_leading_separators() {
|
||||
// Both separator styles are trimmed from the front after the root
|
||||
// is stripped, regardless of which form the join produced.
|
||||
assert_eq!(
|
||||
rel_path_for_lookup(r"Z:\Media\pic\sub\a.mp4", r"Z:\Media\pic"),
|
||||
"sub/a.mp4"
|
||||
);
|
||||
assert_eq!(
|
||||
rel_path_for_lookup("/media/pic//sub/a.mp4", "/media/pic"),
|
||||
"sub/a.mp4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rel_path_for_lookup_falls_back_when_root_does_not_match() {
|
||||
// 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\Melissa\clip.mp4", r"Z:\Media\pic"),
|
||||
"D:/other/Melissa/clip.mp4"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_token() -> String {
|
||||
let claims = Claims::valid_user("1".to_string());
|
||||
jsonwebtoken::encode(
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
//! Per-library HLS readiness: Prometheus gauges + `/hls/stats` endpoint.
|
||||
//!
|
||||
//! The new hash-keyed pipeline transcodes lazily — most of a freshly
|
||||
//! mounted library is "pending" for the first hour, and operators want
|
||||
//! a live read on "how much work is left, am I CPU-bound, do I need to
|
||||
//! bump `HLS_CONCURRENCY`." This module supplies both surfaces against
|
||||
//! the same compute path:
|
||||
//!
|
||||
//! - **Prometheus gauges** `imageserver_hls_videos_total{library}`,
|
||||
//! `..._with_playlist{library}`, `..._pending{library}`,
|
||||
//! `..._unsupported{library}`. Updated every watcher full-scan tick
|
||||
//! and on every `/hls/stats` request, so the freshness matches
|
||||
//! whichever surface the operator is watching.
|
||||
//!
|
||||
//! - **`GET /hls/stats`** returns a JSON snapshot of the same counts
|
||||
//! plus a top-level cross-library aggregate. Claims-protected
|
||||
//! (matches every other authenticated read in this crate).
|
||||
//!
|
||||
//! Cost is O(distinct video hashes per library), each row needing a
|
||||
//! single `stat()` on the playlist file. On a 100k-video library that's
|
||||
//! noticeable; on a typical home library (few thousand) it's noise.
|
||||
//! We call from explicit triggers only — never per-request from
|
||||
//! middleware — so the cost is bounded.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use actix_web::{HttpResponse, Responder, get, web};
|
||||
use lazy_static::lazy_static;
|
||||
use log::{info, warn};
|
||||
use prometheus::IntGaugeVec;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::data::Claims;
|
||||
use crate::database::ExifDao;
|
||||
use crate::file_types;
|
||||
use crate::libraries::Library;
|
||||
use crate::state::AppState;
|
||||
use crate::video::hls_paths;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref HLS_VIDEOS_TOTAL: IntGaugeVec = IntGaugeVec::new(
|
||||
prometheus::Opts::new(
|
||||
"imageserver_hls_videos_total",
|
||||
"Distinct video content hashes per library known to image_exif",
|
||||
),
|
||||
&["library"],
|
||||
)
|
||||
.expect("HLS_VIDEOS_TOTAL");
|
||||
pub static ref HLS_VIDEOS_WITH_PLAYLIST: IntGaugeVec = IntGaugeVec::new(
|
||||
prometheus::Opts::new(
|
||||
"imageserver_hls_videos_with_playlist",
|
||||
"Videos whose hash-keyed HLS playlist is already on disk",
|
||||
),
|
||||
&["library"],
|
||||
)
|
||||
.expect("HLS_VIDEOS_WITH_PLAYLIST");
|
||||
pub static ref HLS_VIDEOS_PENDING: IntGaugeVec = IntGaugeVec::new(
|
||||
prometheus::Opts::new(
|
||||
"imageserver_hls_videos_pending",
|
||||
"Videos whose hash-keyed HLS playlist is not yet on disk",
|
||||
),
|
||||
&["library"],
|
||||
)
|
||||
.expect("HLS_VIDEOS_PENDING");
|
||||
pub static ref HLS_VIDEOS_UNSUPPORTED: IntGaugeVec = IntGaugeVec::new(
|
||||
prometheus::Opts::new(
|
||||
"imageserver_hls_videos_unsupported",
|
||||
"Videos with an `.unsupported` sentinel — ffmpeg refused; \
|
||||
operator must delete to retry",
|
||||
),
|
||||
&["library"],
|
||||
)
|
||||
.expect("HLS_VIDEOS_UNSUPPORTED");
|
||||
}
|
||||
|
||||
/// Per-library HLS readiness snapshot.
|
||||
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HlsLibraryStats {
|
||||
pub library_id: i32,
|
||||
pub library: String,
|
||||
/// Distinct video content hashes (dedupes intra-library bytes-at-N-paths).
|
||||
pub total: usize,
|
||||
/// Of `total`, hashes whose `playlist.m3u8` is on disk.
|
||||
pub with_playlist: usize,
|
||||
/// Of `total`, hashes whose ffmpeg attempt left a `.unsupported`
|
||||
/// sentinel. Counted separately because they won't progress without
|
||||
/// operator intervention (delete the sentinel to retry).
|
||||
pub unsupported: usize,
|
||||
/// `total - (with_playlist + unsupported)` — videos awaiting transcode.
|
||||
pub pending: usize,
|
||||
/// Distinct rel_paths under this library that are video files but
|
||||
/// whose `image_exif.content_hash` is still NULL (mid-backfill).
|
||||
/// These don't yet count toward `total` because they're invisible
|
||||
/// to the hash-keyed pipeline; surfaced so the operator can see
|
||||
/// "hash backfill, then transcode" pipeline depth.
|
||||
pub hashless_videos: usize,
|
||||
}
|
||||
|
||||
/// JSON response body for `GET /hls/stats`.
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct HlsStatsResponse {
|
||||
pub libraries: Vec<HlsLibraryStats>,
|
||||
pub total: usize,
|
||||
pub with_playlist: usize,
|
||||
pub pending: usize,
|
||||
pub unsupported: usize,
|
||||
pub hashless_videos: usize,
|
||||
}
|
||||
|
||||
/// Compute current readiness per library and publish to Prometheus.
|
||||
/// Returns the same data so callers can serialise it. The publish step
|
||||
/// is idempotent on the gauge — old values get overwritten.
|
||||
pub fn compute_and_publish(
|
||||
libraries: &[Library],
|
||||
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
video_dir: &Path,
|
||||
) -> Vec<HlsLibraryStats> {
|
||||
let ctx = opentelemetry::Context::new();
|
||||
let mut out = Vec::with_capacity(libraries.len());
|
||||
for lib in libraries {
|
||||
let stats = compute_for_library(&ctx, lib, exif_dao, video_dir);
|
||||
publish_gauges(&stats);
|
||||
out.push(stats);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn publish_gauges(s: &HlsLibraryStats) {
|
||||
HLS_VIDEOS_TOTAL
|
||||
.with_label_values(&[s.library.as_str()])
|
||||
.set(s.total as i64);
|
||||
HLS_VIDEOS_WITH_PLAYLIST
|
||||
.with_label_values(&[s.library.as_str()])
|
||||
.set(s.with_playlist as i64);
|
||||
HLS_VIDEOS_PENDING
|
||||
.with_label_values(&[s.library.as_str()])
|
||||
.set(s.pending as i64);
|
||||
HLS_VIDEOS_UNSUPPORTED
|
||||
.with_label_values(&[s.library.as_str()])
|
||||
.set(s.unsupported as i64);
|
||||
}
|
||||
|
||||
fn compute_for_library(
|
||||
ctx: &opentelemetry::Context,
|
||||
lib: &Library,
|
||||
exif_dao: &Arc<Mutex<Box<dyn ExifDao>>>,
|
||||
video_dir: &Path,
|
||||
) -> HlsLibraryStats {
|
||||
let rows = {
|
||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
match dao.list_paths_and_hashes_for_library(ctx, lib.id) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"hls_stats: list_paths_and_hashes_for_library failed for lib {}: {:?}",
|
||||
lib.id, e
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
stats_from_rows(lib, &rows, video_dir)
|
||||
}
|
||||
|
||||
/// Pure function — same compute as [`compute_for_library`] but works
|
||||
/// on caller-supplied rows. Split out so tests don't need a full
|
||||
/// `ExifDao` mock; the integration path is exercised through
|
||||
/// `compute_and_publish` against the real SQLite DAO at runtime.
|
||||
fn stats_from_rows(
|
||||
lib: &Library,
|
||||
rows: &[(String, Option<String>)],
|
||||
video_dir: &Path,
|
||||
) -> HlsLibraryStats {
|
||||
let mut hashes: HashSet<String> = HashSet::new();
|
||||
let mut hashless_videos = 0usize;
|
||||
for (rel_path, hash_opt) in rows {
|
||||
if !file_types::is_video_file(Path::new(rel_path)) {
|
||||
continue;
|
||||
}
|
||||
match hash_opt {
|
||||
Some(h) => {
|
||||
hashes.insert(h.clone());
|
||||
}
|
||||
None => {
|
||||
hashless_videos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut with_playlist = 0usize;
|
||||
let mut unsupported = 0usize;
|
||||
for h in &hashes {
|
||||
if hls_paths::playlist_for_hash(video_dir, h).exists() {
|
||||
with_playlist += 1;
|
||||
} else if hls_paths::sentinel_for_hash(video_dir, h).exists() {
|
||||
unsupported += 1;
|
||||
}
|
||||
}
|
||||
let total = hashes.len();
|
||||
let pending = total.saturating_sub(with_playlist + unsupported);
|
||||
|
||||
HlsLibraryStats {
|
||||
library_id: lib.id,
|
||||
library: lib.name.clone(),
|
||||
total,
|
||||
with_playlist,
|
||||
unsupported,
|
||||
pending,
|
||||
hashless_videos,
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a single info line summarising readiness across all libraries.
|
||||
/// Called by the watcher at the end of a full-scan tick so operators
|
||||
/// who tail the log see the headline number without scraping
|
||||
/// Prometheus.
|
||||
pub fn log_summary(stats: &[HlsLibraryStats]) {
|
||||
let total: usize = stats.iter().map(|s| s.total).sum();
|
||||
let with_playlist: usize = stats.iter().map(|s| s.with_playlist).sum();
|
||||
let pending: usize = stats.iter().map(|s| s.pending).sum();
|
||||
let unsupported: usize = stats.iter().map(|s| s.unsupported).sum();
|
||||
let hashless: usize = stats.iter().map(|s| s.hashless_videos).sum();
|
||||
|
||||
let per_lib: Vec<String> = stats
|
||||
.iter()
|
||||
.map(|s| {
|
||||
format!(
|
||||
"{}={}/{} pending={} unsupported={} hashless={}",
|
||||
s.library, s.with_playlist, s.total, s.pending, s.unsupported, s.hashless_videos,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
info!(
|
||||
"HLS readiness: {}/{} playlists on disk, {} pending, {} unsupported, {} hashless videos | per-library: [{}]",
|
||||
with_playlist,
|
||||
total,
|
||||
pending,
|
||||
unsupported,
|
||||
hashless,
|
||||
per_lib.join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
#[get("/hls/stats")]
|
||||
pub async fn hls_stats_handler(
|
||||
_claims: Claims,
|
||||
app_state: web::Data<AppState>,
|
||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> impl Responder {
|
||||
let libraries = app_state.libraries.clone();
|
||||
let video_dir = std::path::PathBuf::from(&app_state.video_path);
|
||||
let exif_dao = exif_dao.into_inner();
|
||||
|
||||
// Synchronous file IO + DB query — run on a blocking pool so the
|
||||
// actix worker thread stays free for other requests.
|
||||
let stats =
|
||||
match web::block(move || compute_and_publish(&libraries, &exif_dao, &video_dir)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("/hls/stats: blocking task failed: {:?}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let total: usize = stats.iter().map(|s| s.total).sum();
|
||||
let with_playlist: usize = stats.iter().map(|s| s.with_playlist).sum();
|
||||
let pending: usize = stats.iter().map(|s| s.pending).sum();
|
||||
let unsupported: usize = stats.iter().map(|s| s.unsupported).sum();
|
||||
let hashless_videos: usize = stats.iter().map(|s| s.hashless_videos).sum();
|
||||
|
||||
HttpResponse::Ok().json(HlsStatsResponse {
|
||||
libraries: stats,
|
||||
total,
|
||||
with_playlist,
|
||||
pending,
|
||||
unsupported,
|
||||
hashless_videos,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn lib(id: i32, name: &str) -> Library {
|
||||
Library {
|
||||
id,
|
||||
name: name.into(),
|
||||
root_path: String::new(),
|
||||
enabled: true,
|
||||
excluded_dirs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(vs: Vec<(&str, Option<&str>)>) -> Vec<(String, Option<String>)> {
|
||||
vs.into_iter()
|
||||
.map(|(p, h)| (p.to_string(), h.map(|s| s.to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn touch(dir: &Path, rel: &str) {
|
||||
let p = dir.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, b"").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn videos_only_count_in_total() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let r = rows(vec![
|
||||
("photos/IMG.jpg", Some(&"a".repeat(64))), // image: ignored
|
||||
("clip.mp4", Some(&"b".repeat(64))),
|
||||
("vid.mov", Some(&"c".repeat(64))),
|
||||
]);
|
||||
let stats = stats_from_rows(&lib(1, "main"), &r, tmp.path());
|
||||
assert_eq!(stats.total, 2);
|
||||
assert_eq!(stats.with_playlist, 0);
|
||||
assert_eq!(stats.pending, 2);
|
||||
assert_eq!(stats.unsupported, 0);
|
||||
assert_eq!(stats.hashless_videos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_dedup_collapses_duplicate_rel_paths() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let r = rows(vec![
|
||||
("a/clip.mp4", Some(&"a".repeat(64))),
|
||||
("b/clip.mp4", Some(&"a".repeat(64))), // same bytes, dup
|
||||
("other.mp4", Some(&"b".repeat(64))),
|
||||
]);
|
||||
let stats = stats_from_rows(&lib(1, "main"), &r, tmp.path());
|
||||
assert_eq!(stats.total, 2, "duplicate hashes collapse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlist_existence_promotes_to_with_playlist() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let hash = "a".repeat(64);
|
||||
touch(tmp.path(), &format!("aa/{}/playlist.m3u8", hash));
|
||||
|
||||
let r = rows(vec![("clip.mp4", Some(&hash))]);
|
||||
let stats = stats_from_rows(&lib(1, "main"), &r, tmp.path());
|
||||
assert_eq!(stats.total, 1);
|
||||
assert_eq!(stats.with_playlist, 1);
|
||||
assert_eq!(stats.pending, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_existence_promotes_to_unsupported() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let hash = "b".repeat(64);
|
||||
touch(tmp.path(), &format!("bb/{}/playlist.unsupported", hash));
|
||||
|
||||
let r = rows(vec![("clip.mov", Some(&hash))]);
|
||||
let stats = stats_from_rows(&lib(1, "main"), &r, tmp.path());
|
||||
assert_eq!(stats.total, 1);
|
||||
assert_eq!(stats.unsupported, 1);
|
||||
assert_eq!(stats.with_playlist, 0);
|
||||
assert_eq!(stats.pending, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_hash_videos_are_hashless_not_total() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let r = rows(vec![
|
||||
("clip.mp4", None),
|
||||
("other.mp4", Some(&"a".repeat(64))),
|
||||
]);
|
||||
let stats = stats_from_rows(&lib(1, "main"), &r, tmp.path());
|
||||
assert_eq!(stats.total, 1, "hashless row excluded from total");
|
||||
assert_eq!(stats.hashless_videos, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_gauges_sets_per_library_value() {
|
||||
let s = HlsLibraryStats {
|
||||
library_id: 7,
|
||||
library: "test_publish_a".into(),
|
||||
total: 5,
|
||||
with_playlist: 2,
|
||||
pending: 3,
|
||||
unsupported: 0,
|
||||
hashless_videos: 0,
|
||||
};
|
||||
publish_gauges(&s);
|
||||
assert_eq!(
|
||||
HLS_VIDEOS_TOTAL
|
||||
.with_label_values(&["test_publish_a"])
|
||||
.get(),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
HLS_VIDEOS_PENDING
|
||||
.with_label_values(&["test_publish_a"])
|
||||
.get(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
HLS_VIDEOS_WITH_PLAYLIST
|
||||
.with_label_values(&["test_publish_a"])
|
||||
.get(),
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
+38
-31
@@ -444,7 +444,8 @@ where
|
||||
)
|
||||
.service(web::resource("/graph").route(web::get().to(get_graph::<D>)))
|
||||
.service(
|
||||
web::resource("/predicate-stats").route(web::get().to(get_predicate_stats::<D>)),
|
||||
web::resource("/predicate-stats")
|
||||
.route(web::get().to(get_predicate_stats::<D>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/predicates/{predicate}/bulk-reject")
|
||||
@@ -803,36 +804,38 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
|
||||
.json(serde_json::json!({"error": "source_id and target_id must differ"}));
|
||||
}
|
||||
|
||||
let (source, target) = {
|
||||
let cx = opentelemetry::Context::current();
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
let cx = opentelemetry::Context::current();
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
|
||||
let source = match dao.get_entity_by_id(&cx, body.source_id) {
|
||||
Ok(Some(e)) => e,
|
||||
Ok(None) => {
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "source entity not found"}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("synthesize_merge source lookup: {:?}", e);
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
};
|
||||
let target = match dao.get_entity_by_id(&cx, body.target_id) {
|
||||
Ok(Some(e)) => e,
|
||||
Ok(None) => {
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "target entity not found"}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("synthesize_merge target lookup: {:?}", e);
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
};
|
||||
(source, target)
|
||||
let source = match dao.get_entity_by_id(&cx, body.source_id) {
|
||||
Ok(Some(e)) => e,
|
||||
Ok(None) => {
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "source entity not found"}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("synthesize_merge source lookup: {:?}", e);
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
};
|
||||
let target = match dao.get_entity_by_id(&cx, body.target_id) {
|
||||
Ok(Some(e)) => e,
|
||||
Ok(None) => {
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "target entity not found"}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("synthesize_merge target lookup: {:?}", e);
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
};
|
||||
|
||||
// Drop the DAO lock before the LLM call — the generate request
|
||||
// is the slow part (seconds) and we don't want to block other
|
||||
// knowledge reads while it runs.
|
||||
drop(dao);
|
||||
|
||||
let source_desc = if source.description.trim().is_empty() {
|
||||
"(none)".to_string()
|
||||
@@ -1258,8 +1261,12 @@ async fn bulk_reject_predicate<D: KnowledgeDao + 'static>(
|
||||
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
|
||||
let cx = opentelemetry::Context::current();
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
match dao.bulk_reject_facts_by_predicate(&cx, &persona, &predicate, Some(("manual", "manual")))
|
||||
{
|
||||
match dao.bulk_reject_facts_by_predicate(
|
||||
&cx,
|
||||
&persona,
|
||||
&predicate,
|
||||
Some(("manual", "manual")),
|
||||
) {
|
||||
Ok(rejected) => HttpResponse::Ok().json(BulkRejectResponse { rejected }),
|
||||
Err(e) => {
|
||||
log::error!("bulk_reject_predicate error: {:?}", e);
|
||||
|
||||
@@ -7,8 +7,6 @@ pub mod ai;
|
||||
pub mod auth;
|
||||
pub mod bin_progress;
|
||||
pub mod cleanup;
|
||||
pub mod clip_search;
|
||||
pub mod clip_watch;
|
||||
pub mod content_hash;
|
||||
pub mod data;
|
||||
pub mod database;
|
||||
@@ -35,7 +33,6 @@ pub mod tags;
|
||||
#[cfg(test)]
|
||||
pub mod testhelpers;
|
||||
pub mod thumbnails;
|
||||
pub mod unified_search;
|
||||
pub mod utils;
|
||||
pub mod video;
|
||||
|
||||
|
||||
+49
-53
@@ -94,7 +94,7 @@ pub fn parse_excluded_dirs_column(raw: Option<&str>) -> Vec<String> {
|
||||
match raw {
|
||||
None => Vec::new(),
|
||||
Some(s) => s
|
||||
.split([',', '\n', '\r'])
|
||||
.split(|c: char| matches!(c, ',' | '\n' | '\r'))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
@@ -148,7 +148,10 @@ pub fn validate_excluded_dirs_entry(entry: &str) -> Result<String, String> {
|
||||
if let Some(rel) = trimmed.strip_prefix('/') {
|
||||
// Path form. Reject `..` traversal — `base.join(\"../x\")` doesn't
|
||||
// canonicalise, so `path.starts_with(...)` never matches.
|
||||
if rel.split('/').any(|seg| seg == "..") {
|
||||
if rel
|
||||
.split('/')
|
||||
.any(|seg| seg == "..")
|
||||
{
|
||||
return Err(format!(
|
||||
"'{}': '..' segments don't normalise — the prefix-match never fires",
|
||||
trimmed
|
||||
@@ -291,11 +294,11 @@ pub fn seed_or_patch_from_env(conn: &mut SqliteConnection, base_path: &str) {
|
||||
}
|
||||
|
||||
/// Resolve a library request parameter (accepts numeric id as string or name)
|
||||
/// against a list of libraries. Returns `Ok(None)` when the param is
|
||||
/// against the configured 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.
|
||||
pub fn resolve_library_param<'a>(
|
||||
libs: &'a [Library],
|
||||
state: &'a AppState,
|
||||
param: Option<&str>,
|
||||
) -> Result<Option<&'a Library>, String> {
|
||||
let Some(raw) = param.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
@@ -303,29 +306,18 @@ pub fn resolve_library_param<'a>(
|
||||
};
|
||||
|
||||
if let Ok(id) = raw.parse::<i32>() {
|
||||
return libs
|
||||
.iter()
|
||||
.find(|l| l.id == id)
|
||||
return state
|
||||
.library_by_id(id)
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("unknown library id: {}", id));
|
||||
}
|
||||
|
||||
libs.iter()
|
||||
.find(|l| l.name == raw)
|
||||
state
|
||||
.library_by_name(raw)
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("unknown library name: {}", raw))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn resolve_library_param_state<'a>(
|
||||
state: &'a AppState,
|
||||
param: Option<&str>,
|
||||
) -> Result<Option<&'a Library>, String> {
|
||||
resolve_library_param(&state.libraries, param)
|
||||
}
|
||||
|
||||
/// Health of a library at a point in time. Probed at the top of each
|
||||
/// file-watcher tick. The `Stale` state is the "be conservative" signal:
|
||||
/// destructive paths (ingest writes, future move-handoff and orphan GC in
|
||||
@@ -550,10 +542,7 @@ pub async fn patch_library(
|
||||
{
|
||||
Ok(n) => affected = affected.max(n),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"PATCH /libraries/{}: enabled update failed: {:?}",
|
||||
lib_id, e
|
||||
);
|
||||
warn!("PATCH /libraries/{}: enabled update failed: {:?}", lib_id, e);
|
||||
return HttpResponse::InternalServerError().body(format!("{}", e));
|
||||
}
|
||||
}
|
||||
@@ -611,9 +600,7 @@ pub async fn patch_library(
|
||||
);
|
||||
HttpResponse::Ok().json(lib)
|
||||
}
|
||||
None => {
|
||||
HttpResponse::NotFound().body(format!("library id {} not found after update", lib_id))
|
||||
}
|
||||
None => HttpResponse::NotFound().body(format!("library id {} not found after update", lib_id)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +660,12 @@ mod tests {
|
||||
assert_eq!(abs, PathBuf::from("/tmp/media/2024/photo.jpg"));
|
||||
}
|
||||
|
||||
fn state_with_libraries(libs: Vec<Library>) -> AppState {
|
||||
let mut state = AppState::test_state();
|
||||
state.libraries = libs;
|
||||
state
|
||||
}
|
||||
|
||||
fn sample_libraries() -> Vec<Library> {
|
||||
vec![
|
||||
Library {
|
||||
@@ -692,52 +685,52 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_library_param_absent_is_union() {
|
||||
let libs = sample_libraries();
|
||||
assert!(matches!(resolve_library_param(&libs, None), Ok(None)));
|
||||
#[actix_rt::test]
|
||||
async fn resolve_library_param_absent_is_union() {
|
||||
let state = state_with_libraries(sample_libraries());
|
||||
assert!(matches!(resolve_library_param(&state, None), Ok(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_library_param_empty_or_whitespace_is_union() {
|
||||
let libs = sample_libraries();
|
||||
assert!(matches!(resolve_library_param(&libs, Some("")), Ok(None)));
|
||||
#[actix_rt::test]
|
||||
async fn resolve_library_param_empty_or_whitespace_is_union() {
|
||||
let state = state_with_libraries(sample_libraries());
|
||||
assert!(matches!(resolve_library_param(&state, Some("")), Ok(None)));
|
||||
assert!(matches!(
|
||||
resolve_library_param(&libs, Some(" ")),
|
||||
resolve_library_param(&state, Some(" ")),
|
||||
Ok(None)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_library_param_numeric_id_matches() {
|
||||
let libs = sample_libraries();
|
||||
let lib = resolve_library_param(&libs, Some("7"))
|
||||
#[actix_rt::test]
|
||||
async fn resolve_library_param_numeric_id_matches() {
|
||||
let state = state_with_libraries(sample_libraries());
|
||||
let lib = resolve_library_param(&state, Some("7"))
|
||||
.expect("valid id")
|
||||
.expect("some library");
|
||||
assert_eq!(lib.id, 7);
|
||||
assert_eq!(lib.name, "archive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_library_param_name_matches() {
|
||||
let libs = sample_libraries();
|
||||
let lib = resolve_library_param(&libs, Some("main"))
|
||||
#[actix_rt::test]
|
||||
async fn resolve_library_param_name_matches() {
|
||||
let state = state_with_libraries(sample_libraries());
|
||||
let lib = resolve_library_param(&state, Some("main"))
|
||||
.expect("valid name")
|
||||
.expect("some library");
|
||||
assert_eq!(lib.id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_library_param_unknown_id_errs() {
|
||||
let libs = sample_libraries();
|
||||
let err = resolve_library_param(&libs, Some("999")).unwrap_err();
|
||||
#[actix_rt::test]
|
||||
async fn resolve_library_param_unknown_id_errs() {
|
||||
let state = state_with_libraries(sample_libraries());
|
||||
let err = resolve_library_param(&state, Some("999")).unwrap_err();
|
||||
assert!(err.contains("unknown library id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_library_param_unknown_name_errs() {
|
||||
let libs = sample_libraries();
|
||||
let err = resolve_library_param(&libs, Some("missing")).unwrap_err();
|
||||
#[actix_rt::test]
|
||||
async fn resolve_library_param_unknown_name_errs() {
|
||||
let state = state_with_libraries(sample_libraries());
|
||||
let err = resolve_library_param(&state, Some("missing")).unwrap_err();
|
||||
assert!(err.contains("unknown library name"));
|
||||
}
|
||||
|
||||
@@ -937,7 +930,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn validate_strips_trailing_slash_on_path_entries() {
|
||||
assert_eq!(validate_excluded_dirs_entry("/photos/").unwrap(), "/photos");
|
||||
assert_eq!(
|
||||
validate_excluded_dirs_entry("/photos/").unwrap(),
|
||||
"/photos"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_excluded_dirs_entry("/photos//").unwrap(),
|
||||
"/photos"
|
||||
@@ -1057,7 +1053,7 @@ mod tests {
|
||||
enabled: true,
|
||||
excluded_dirs: Vec::new(),
|
||||
};
|
||||
let map = new_health_map(std::slice::from_ref(&lib));
|
||||
let map = new_health_map(&[lib.clone()]);
|
||||
|
||||
// First probe: empty dir, no prior data — Online.
|
||||
let s1 = refresh_health(&map, &lib, false);
|
||||
|
||||
@@ -296,7 +296,6 @@ impl GcStats {
|
||||
|| self.revived > 0
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn total_deleted(&self) -> usize {
|
||||
self.deleted_face_detections + self.deleted_tagged_photo + self.deleted_photo_insights
|
||||
}
|
||||
|
||||
+9
-116
@@ -26,13 +26,12 @@ use crate::files::{RealFileSystem, move_file};
|
||||
use crate::service::ServiceBuilder;
|
||||
use crate::state::AppState;
|
||||
use crate::tags::*;
|
||||
use crate::video::actors::ScanDirectoryMessage;
|
||||
use log::{error, info};
|
||||
|
||||
mod ai;
|
||||
mod auth;
|
||||
mod backfill;
|
||||
mod clip_search;
|
||||
mod clip_watch;
|
||||
mod content_hash;
|
||||
mod data;
|
||||
mod database;
|
||||
@@ -47,14 +46,12 @@ mod file_types;
|
||||
mod files;
|
||||
mod geo;
|
||||
mod handlers;
|
||||
mod hls_stats;
|
||||
mod libraries;
|
||||
mod library_maintenance;
|
||||
mod perceptual_hash;
|
||||
mod state;
|
||||
mod tags;
|
||||
mod thumbnails;
|
||||
mod unified_search;
|
||||
mod utils;
|
||||
mod video;
|
||||
mod watcher;
|
||||
@@ -63,7 +60,6 @@ mod knowledge;
|
||||
mod memories;
|
||||
mod otel;
|
||||
mod personas;
|
||||
mod reels;
|
||||
mod service;
|
||||
#[cfg(test)]
|
||||
mod testhelpers;
|
||||
@@ -77,32 +73,6 @@ fn main() -> std::io::Result<()> {
|
||||
|
||||
run_migrations(&mut connect()).expect("Failed to run migrations");
|
||||
|
||||
// Recover orphaned insight generation jobs from a previous crash.
|
||||
{
|
||||
use crate::database::{InsightGenerationJobDao, SqliteInsightGenerationJobDao};
|
||||
let mut dao = SqliteInsightGenerationJobDao::new();
|
||||
let ctx = opentelemetry::Context::new();
|
||||
match dao.recover_orphaned_jobs(&ctx) {
|
||||
Ok(n) if n > 0 => {
|
||||
info!("Recovered {} orphaned insight generation jobs", n);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to recover orphaned insight jobs: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot retirement of the pre-content-hash HLS layout. Idempotent
|
||||
// — a second boot finds nothing and reports zero deletions, so it's
|
||||
// safe to leave wired in until the module is removed in a later
|
||||
// release. Runs before the actor pipeline starts so we never race a
|
||||
// PlaylistGenerator write against this rm.
|
||||
{
|
||||
let video_path = env::var("VIDEO_PATH").expect("VIDEO_PATH was not set in the env");
|
||||
video::legacy_migration::retire_legacy_hls_output(std::path::Path::new(&video_path));
|
||||
}
|
||||
|
||||
let system = actix::System::new();
|
||||
system.block_on(async {
|
||||
// Just use basic logger when running a non-release build
|
||||
@@ -147,32 +117,15 @@ fn main() -> std::io::Result<()> {
|
||||
.registry
|
||||
.register(Box::new(thumbnails::VIDEO_GAUGE.clone()))
|
||||
.unwrap();
|
||||
// HLS readiness gauges. Updated by the watcher every full-scan
|
||||
// tick and on every `/hls/stats` request. See `hls_stats`.
|
||||
prometheus
|
||||
.registry
|
||||
.register(Box::new(hls_stats::HLS_VIDEOS_TOTAL.clone()))
|
||||
.unwrap();
|
||||
prometheus
|
||||
.registry
|
||||
.register(Box::new(hls_stats::HLS_VIDEOS_WITH_PLAYLIST.clone()))
|
||||
.unwrap();
|
||||
prometheus
|
||||
.registry
|
||||
.register(Box::new(hls_stats::HLS_VIDEOS_PENDING.clone()))
|
||||
.unwrap();
|
||||
prometheus
|
||||
.registry
|
||||
.register(Box::new(hls_stats::HLS_VIDEOS_UNSUPPORTED.clone()))
|
||||
.unwrap();
|
||||
|
||||
let app_state = app_data.clone();
|
||||
for lib in &app_state.libraries {
|
||||
app_state.playlist_manager.do_send(ScanDirectoryMessage {
|
||||
directory: lib.root_path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Start file watcher with playlist manager and preview generator.
|
||||
// The watcher's first tick is configured to be a full scan (see
|
||||
// `watch_files`), so every library's missing HLS playlists are
|
||||
// queued on that first iteration — no separate startup walk
|
||||
// needed.
|
||||
// Start file watcher with playlist manager and preview generator
|
||||
let playlist_mgr_for_watcher = app_state.playlist_manager.as_ref().clone();
|
||||
let preview_gen_for_watcher = app_state.preview_clip_generator.as_ref().clone();
|
||||
// Both background jobs read from the shared `live_libraries` lock
|
||||
@@ -184,7 +137,6 @@ fn main() -> std::io::Result<()> {
|
||||
playlist_mgr_for_watcher,
|
||||
preview_gen_for_watcher,
|
||||
app_state.face_client.clone(),
|
||||
app_state.clip_client.clone(),
|
||||
app_state.excluded_dirs.clone(),
|
||||
app_state.library_health.clone(),
|
||||
);
|
||||
@@ -199,28 +151,6 @@ fn main() -> std::io::Result<()> {
|
||||
app_state.library_health.clone(),
|
||||
);
|
||||
|
||||
// Periodically clean up stale turn entries from the in-memory
|
||||
// registry. Runs at the same interval as the configured timeout,
|
||||
// drops entries older than that timeout.
|
||||
{
|
||||
let registry = app_state.turn_registry.clone();
|
||||
let timeout_secs = registry.timeout_secs();
|
||||
tokio::spawn(async move {
|
||||
// Sweep at most every 5 minutes, and never less often than the
|
||||
// timeout itself — otherwise entries could linger up to ~2× the
|
||||
// configured timeout before being reclaimed.
|
||||
let interval_secs = timeout_secs.clamp(1, 300);
|
||||
let interval = tokio::time::Duration::from_secs(interval_secs);
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
let cleaned = registry.cleanup_stale().await;
|
||||
if cleaned > 0 {
|
||||
log::info!("TurnRegistry: cleaned up {cleaned} stale entries");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn background job to generate daily conversation summaries
|
||||
{
|
||||
use crate::ai::generate_daily_summaries;
|
||||
@@ -268,11 +198,6 @@ fn main() -> std::io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the nightly pre-generation scheduler (Section D).
|
||||
reels::spawn_pregen_scheduler(app_state.clone()).await;
|
||||
// Spawn the on-disk reel-cache sweeper (bounds pre-gen + on-demand reels).
|
||||
reels::spawn_reel_cache_sweeper(app_state.clone()).await;
|
||||
|
||||
HttpServer::new(move || {
|
||||
let user_dao = SqliteUserDao::new();
|
||||
let favorites_dao = SqliteFavoriteDao::new();
|
||||
@@ -328,27 +253,14 @@ fn main() -> std::io::Result<()> {
|
||||
.service(
|
||||
web::resource("/photos/exif").route(web::get().to(files::list_exif_summary)),
|
||||
)
|
||||
.service(
|
||||
// Semantic search via CLIP embeddings. See
|
||||
// src/clip_search.rs for the request/response shape.
|
||||
web::resource("/photos/search")
|
||||
.route(web::get().to(clip_search::search_photos)),
|
||||
)
|
||||
.service(
|
||||
// Unified natural-language search: LLM translates the
|
||||
// query into structured filters + a semantic term, then
|
||||
// filters constrain and CLIP ranks. See src/unified_search.rs.
|
||||
web::resource("/photos/search/unified")
|
||||
.route(web::get().to(unified_search::unified_search::<SqliteTagDao>)),
|
||||
)
|
||||
.service(web::resource("/file/move").post(move_file::<RealFileSystem>))
|
||||
.service(handlers::image::get_image)
|
||||
.service(handlers::image::upload_image)
|
||||
.service(handlers::video::generate_video)
|
||||
.service(handlers::video::stream_hls_file)
|
||||
.service(handlers::video::stream_video)
|
||||
.service(handlers::video::get_video_preview)
|
||||
.service(handlers::video::get_preview_status)
|
||||
.service(hls_stats::hls_stats_handler)
|
||||
.service(handlers::video::get_video_part)
|
||||
.service(handlers::favorites::favorites)
|
||||
.service(handlers::favorites::put_add_favorite)
|
||||
.service(handlers::favorites::delete_favorite)
|
||||
@@ -358,38 +270,19 @@ fn main() -> std::io::Result<()> {
|
||||
.service(handlers::image::clear_image_date)
|
||||
.service(handlers::image::get_full_exif)
|
||||
.service(memories::list_memories)
|
||||
.service(reels::create_reel_handler)
|
||||
.service(reels::reel_status_handler)
|
||||
.service(reels::reel_video_handler)
|
||||
.service(reels::precomputed_reel_handler)
|
||||
.service(reels::precomputed_video_handler)
|
||||
.service(ai::generate_insight_handler)
|
||||
.service(ai::generate_agentic_insight_handler)
|
||||
.service(ai::generation_status_handler)
|
||||
.service(ai::cancel_generation_handler)
|
||||
.service(ai::get_insight_handler)
|
||||
.service(ai::delete_insight_handler)
|
||||
.service(ai::get_all_insights_handler)
|
||||
.service(ai::get_insight_history_handler)
|
||||
.service(ai::get_available_models_handler)
|
||||
.service(ai::get_openrouter_models_handler)
|
||||
.service(ai::chat_turn_handler)
|
||||
.service(ai::chat_stream_handler)
|
||||
.service(ai::chat_history_handler)
|
||||
.service(ai::chat_rewind_handler)
|
||||
.service(ai::turn_async_handler)
|
||||
.service(ai::turn_replay_handler)
|
||||
.service(ai::cancel_turn_handler)
|
||||
.service(ai::rate_insight_handler)
|
||||
.service(ai::export_training_data_handler)
|
||||
.service(ai::tts_speech_handler)
|
||||
.service(ai::create_speech_job_handler)
|
||||
.service(ai::speech_job_status_handler)
|
||||
.service(ai::cancel_speech_job_handler)
|
||||
.service(ai::list_voices_handler)
|
||||
.service(ai::create_voice_upload_handler)
|
||||
.service(ai::create_voice_from_library_handler)
|
||||
.service(ai::delete_voice_handler)
|
||||
.service(libraries::list_libraries)
|
||||
.service(libraries::patch_library)
|
||||
.add_feature(add_tag_services::<_, SqliteTagDao>)
|
||||
|
||||
+29
-57
@@ -349,6 +349,12 @@ pub async fn list_memories(
|
||||
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
|
||||
|
||||
let span_mode = q.span.unwrap_or(MemoriesSpan::Day);
|
||||
let span_token = match span_mode {
|
||||
MemoriesSpan::Day => "day",
|
||||
MemoriesSpan::Week => "week",
|
||||
MemoriesSpan::Month => "month",
|
||||
};
|
||||
let years_back: i32 = DEFAULT_YEARS_BACK;
|
||||
|
||||
// The SQL filter expects a signed offset in minutes from UTC; default
|
||||
// 0 (UTC) when the client didn't send a hint. We also keep a chrono
|
||||
@@ -360,66 +366,18 @@ pub async fn list_memories(
|
||||
.timezone_offset_minutes
|
||||
.and_then(|offset_mins| FixedOffset::east_opt(offset_mins * 60));
|
||||
|
||||
let items = match gather_memory_items(
|
||||
&app_state,
|
||||
&exif_dao,
|
||||
&span_context,
|
||||
span_mode,
|
||||
tz_offset_minutes,
|
||||
client_timezone,
|
||||
q.library.as_deref(),
|
||||
) {
|
||||
Ok(items) => items,
|
||||
debug!(
|
||||
"list_memories: span={:?} tz_offset_min={} years_back={}",
|
||||
span_mode, tz_offset_minutes, years_back
|
||||
);
|
||||
|
||||
let library = match crate::libraries::resolve_library_param(&app_state, q.library.as_deref()) {
|
||||
Ok(lib) => lib,
|
||||
Err(msg) => {
|
||||
warn!("Rejecting /memories request: {}", msg);
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
|
||||
span.add_event(
|
||||
"memories_scanned",
|
||||
vec![
|
||||
KeyValue::new("span", format!("{:?}", span_mode)),
|
||||
KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()),
|
||||
KeyValue::new("result_count", items.len().to_string()),
|
||||
KeyValue::new("tz_offset_minutes", tz_offset_minutes.to_string()),
|
||||
KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
|
||||
],
|
||||
);
|
||||
span.set_status(Status::Ok);
|
||||
|
||||
HttpResponse::Ok().json(MemoriesResponse { items })
|
||||
}
|
||||
|
||||
/// Resolve an "on this day/week/month across past years" window into an
|
||||
/// ordered list of [`MemoryItem`]s. Shared by the `/memories` handler and the
|
||||
/// memory-reel selector so both honour the same library resolution, per-library
|
||||
/// exclusions, timezone handling, and sort order. Returns `Err(message)` only
|
||||
/// when the `library` param is invalid (callers map that to 400); per-library
|
||||
/// query/lock failures are logged and skipped, matching the handler's
|
||||
/// best-effort behaviour.
|
||||
pub fn gather_memory_items(
|
||||
app_state: &AppState,
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
span_context: &opentelemetry::Context,
|
||||
span_mode: MemoriesSpan,
|
||||
tz_offset_minutes: i32,
|
||||
client_timezone: Option<FixedOffset>,
|
||||
library_param: Option<&str>,
|
||||
) -> Result<Vec<MemoryItem>, String> {
|
||||
let span_token = match span_mode {
|
||||
MemoriesSpan::Day => "day",
|
||||
MemoriesSpan::Week => "week",
|
||||
MemoriesSpan::Month => "month",
|
||||
};
|
||||
let years_back: i32 = DEFAULT_YEARS_BACK;
|
||||
|
||||
debug!(
|
||||
"gather_memory_items: span={:?} tz_offset_min={} years_back={}",
|
||||
span_mode, tz_offset_minutes, years_back
|
||||
);
|
||||
|
||||
let library = crate::libraries::resolve_library_param_state(app_state, library_param)?;
|
||||
let libraries_to_scan: Vec<&crate::libraries::Library> = match library {
|
||||
Some(lib) => vec![lib],
|
||||
None => app_state.libraries.iter().collect(),
|
||||
@@ -436,7 +394,7 @@ pub fn gather_memory_items(
|
||||
|
||||
let rows = match exif_dao.lock() {
|
||||
Ok(mut dao) => match dao.get_memories_in_window(
|
||||
span_context,
|
||||
&span_context,
|
||||
lib.id,
|
||||
span_token,
|
||||
years_back,
|
||||
@@ -511,7 +469,21 @@ pub fn gather_memory_items(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(memories_with_dates.into_iter().map(|(m, _)| m).collect())
|
||||
let items: Vec<MemoryItem> = memories_with_dates.into_iter().map(|(m, _)| m).collect();
|
||||
|
||||
span.add_event(
|
||||
"memories_scanned",
|
||||
vec![
|
||||
KeyValue::new("span", format!("{:?}", span_mode)),
|
||||
KeyValue::new("years_back", years_back.to_string()),
|
||||
KeyValue::new("result_count", items.len().to_string()),
|
||||
KeyValue::new("tz_offset_minutes", tz_offset_minutes.to_string()),
|
||||
KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
|
||||
],
|
||||
);
|
||||
span.set_status(Status::Ok);
|
||||
|
||||
HttpResponse::Ok().json(MemoriesResponse { items })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
-1568
File diff suppressed because it is too large
Load Diff
@@ -1,742 +0,0 @@
|
||||
//! ffmpeg assembly for memory reels.
|
||||
//!
|
||||
//! Two-stage, per-segment design: each segment is rendered to its own
|
||||
//! normalized MP4 (identical codec/resolution/fps/timebase), then the segments
|
||||
//! are joined with the concat demuxer (stream copy, no re-encode). Rendering
|
||||
//! per segment — rather than one monster filtergraph — keeps each ffmpeg
|
||||
//! invocation simple to reason about, parallelizes naturally, and means a
|
||||
//! video-clip segment type (phase 2) slots in as just a different per-segment
|
||||
//! builder without touching the concat stage.
|
||||
//!
|
||||
//! The arg builders are pure (`Vec<String>` out) so the exact ffmpeg command
|
||||
//! is unit-testable; the runners spawn ffmpeg and surface stderr on failure.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Re-exported so the reel pipeline reaches NVENC detection through this module
|
||||
/// rather than depending on `video::ffmpeg` directly.
|
||||
pub use crate::video::ffmpeg::is_nvenc_available;
|
||||
|
||||
/// Reel canvas. Portrait, because reels are watched on a phone held upright —
|
||||
/// a landscape canvas letterboxes to a thin ~25%-height band there. Each photo
|
||||
/// is fitted sharp and centered over a blurred, zoomed copy of itself (see
|
||||
/// [`photo_filter_chain`]) so the frame is always filled regardless of the
|
||||
/// photo's orientation, without cropping the subject.
|
||||
pub const REEL_WIDTH: u32 = 1080;
|
||||
pub const REEL_HEIGHT: u32 = 1920;
|
||||
pub const REEL_FPS: u32 = 30;
|
||||
|
||||
/// A beat's screen time is its narration length plus a short breath, with a
|
||||
/// floor so a terse line still lingers. No ceiling: the beat always covers the
|
||||
/// full narration so speech is never truncated — the scripter is asked to keep
|
||||
/// lines short instead.
|
||||
pub const MIN_SEGMENT_SECONDS: f64 = 2.5;
|
||||
const NARRATION_TAIL_SECONDS: f64 = 0.6;
|
||||
|
||||
/// Fade durations baked into each photo. A held (single-photo) beat gets a
|
||||
/// gentle dip; burst photos get a much snappier fade so the difference between
|
||||
/// a held shot and a quick burst is obvious.
|
||||
const SINGLE_FADE_SECONDS: f64 = 0.35;
|
||||
const BURST_FADE_SECONDS: f64 = 0.12;
|
||||
|
||||
/// Video-clip framing. Fallback cap on how much of a clip we read when the
|
||||
/// source length can't be probed; with a known length, a clip instead plays for
|
||||
/// as much of its beat as its footage allows (see [`clip_beat_plan`]). Its live
|
||||
/// audio is ducked to `CLIP_DUCK_VOLUME` under the narration.
|
||||
pub const CLIP_SECONDS: f64 = 5.0;
|
||||
const CLIP_DUCK_VOLUME: f64 = 0.35;
|
||||
|
||||
/// Floor on how long each burst photo stays up, so a long line over many photos
|
||||
/// doesn't flash them subliminally. If the narration is too short to give every
|
||||
/// photo this much, the beat is stretched to fit.
|
||||
const MIN_BURST_PHOTO_SECONDS: f64 = 0.6;
|
||||
|
||||
/// Base screen time for a beat given its narration length: narration + breath,
|
||||
/// floored. Used as the lower bound on a beat's total duration.
|
||||
pub fn segment_duration(narration_secs: f64) -> f64 {
|
||||
let d = narration_secs + NARRATION_TAIL_SECONDS;
|
||||
if d.is_finite() && d > MIN_SEGMENT_SECONDS {
|
||||
d
|
||||
} else {
|
||||
MIN_SEGMENT_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a beat into per-photo durations. The beat lasts at least its narration
|
||||
/// (so speech isn't cut) and at least `n × MIN_BURST_PHOTO_SECONDS` (so a fast
|
||||
/// burst stays legible); the photos share that total evenly. Returns
|
||||
/// `(total_seconds, per_photo_seconds)`.
|
||||
pub fn beat_durations(narration_secs: f64, n_photos: usize) -> (f64, Vec<f64>) {
|
||||
let n = n_photos.max(1);
|
||||
let base = segment_duration(narration_secs);
|
||||
let min_total = n as f64 * MIN_BURST_PHOTO_SECONDS;
|
||||
let total = if base > min_total { base } else { min_total };
|
||||
let each = total / n as f64;
|
||||
(total, vec![each; n])
|
||||
}
|
||||
|
||||
/// Fade length to use for a beat of `n_photos` (gentle when held, snappy in a
|
||||
/// burst).
|
||||
fn fade_for(n_photos: usize) -> f64 {
|
||||
if n_photos > 1 {
|
||||
BURST_FADE_SECONDS
|
||||
} else {
|
||||
SINGLE_FADE_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
/// Options controlling per-segment rendering.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SegmentOpts {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub nvenc: bool,
|
||||
}
|
||||
|
||||
impl Default for SegmentOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: REEL_WIDTH,
|
||||
height: REEL_HEIGHT,
|
||||
fps: REEL_FPS,
|
||||
nvenc: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter chain for one photo (input `idx`) producing the labelled output
|
||||
/// `[v{idx}]`. Splits the still into a background and foreground: the background
|
||||
/// is scaled to *cover* the canvas and heavily blurred; the foreground is
|
||||
/// scaled to *fit* and overlaid centered. This fills the portrait frame for any
|
||||
/// photo orientation — no black bars, no cropping of the subject — then a fade
|
||||
/// in/out softens the cut. Intermediate labels are suffixed with `idx` so
|
||||
/// several chains coexist in one `filter_complex`.
|
||||
///
|
||||
/// `fps` is normalized BEFORE the fades so the brightness ramp is computed on a
|
||||
/// true {fps}-frame timeline; otherwise the fade is sampled at the looped
|
||||
/// still's coarse cadence and duplicated up, which reads as a steppy dip.
|
||||
fn photo_filter_chain(idx: usize, opts: &SegmentOpts, duration: f64, fade: f64) -> String {
|
||||
let (w, h, fps) = (opts.width, opts.height, opts.fps);
|
||||
let fade_out_start = (duration - fade).max(0.0);
|
||||
format!(
|
||||
"[{idx}:v]split=2[bg{idx}][fg{idx}];\
|
||||
[bg{idx}]scale={w}:{h}:force_original_aspect_ratio=increase,\
|
||||
crop={w}:{h},boxblur=20:2[bgb{idx}];\
|
||||
[fg{idx}]scale={w}:{h}:force_original_aspect_ratio=decrease[fgs{idx}];\
|
||||
[bgb{idx}][fgs{idx}]overlay=(W-w)/2:(H-h)/2,\
|
||||
fps={fps},\
|
||||
fade=t=in:st=0:d={fade},\
|
||||
fade=t=out:st={fade_out_start:.3}:d={fade},\
|
||||
setsar=1,format=yuv420p[v{idx}]"
|
||||
)
|
||||
}
|
||||
|
||||
/// Full `filter_complex` for a beat of `per_photo` durations: one chain per
|
||||
/// photo, concatenated into `[v]`, with the narration (the last input, index
|
||||
/// `per_photo.len()`) padded with trailing silence into `[a]`. A single-photo
|
||||
/// beat degenerates to one chain + `concat=n=1` (a passthrough).
|
||||
pub fn beat_filtergraph(opts: &SegmentOpts, per_photo: &[f64]) -> String {
|
||||
let n = per_photo.len().max(1);
|
||||
let fade = fade_for(n);
|
||||
let chains: Vec<String> = per_photo
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &d)| photo_filter_chain(i, opts, d, fade))
|
||||
.collect();
|
||||
let concat_inputs: String = (0..n).map(|i| format!("[v{i}]")).collect();
|
||||
format!(
|
||||
"{chains};{concat_inputs}concat=n={n}:v=1:a=0[v];[{n}:a]apad[a]",
|
||||
chains = chains.join(";")
|
||||
)
|
||||
}
|
||||
|
||||
fn video_encoder_args(nvenc: bool) -> Vec<String> {
|
||||
if nvenc {
|
||||
// p4 ≈ balanced; cq 23 ≈ libx264 crf 21. Matches the HLS transcode path.
|
||||
[
|
||||
"-c:v",
|
||||
"h264_nvenc",
|
||||
"-preset",
|
||||
"p4",
|
||||
"-cq",
|
||||
"23",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
]
|
||||
} else {
|
||||
[
|
||||
"-c:v", "libx264", "-crf", "21", "-preset", "veryfast", "-pix_fmt", "yuv420p",
|
||||
]
|
||||
}
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the ffmpeg args that render one beat: each photo looped for its slice
|
||||
/// of the beat (filled to the portrait canvas with a blurred backdrop), the
|
||||
/// slices concatenated, and the single narration muxed over the whole thing.
|
||||
/// `total` bounds the output (and the apad'd audio) to the beat length.
|
||||
pub fn build_beat_args(
|
||||
image_paths: &[String],
|
||||
audio_path: &str,
|
||||
out_path: &str,
|
||||
per_photo: &[f64],
|
||||
total: f64,
|
||||
opts: &SegmentOpts,
|
||||
) -> Vec<String> {
|
||||
let fps = opts.fps.to_string();
|
||||
let mut args: Vec<String> = vec!["-y".into()];
|
||||
if opts.nvenc {
|
||||
args.extend(["-hwaccel".into(), "cuda".into()]);
|
||||
}
|
||||
// One looped-still input per photo, each bounded to its slice by an input
|
||||
// `-t`; reading at the target `-framerate` gives the fades real frames to
|
||||
// ramp across.
|
||||
for (path, &dur) in image_paths.iter().zip(per_photo.iter()) {
|
||||
args.extend([
|
||||
"-framerate".into(),
|
||||
fps.clone(),
|
||||
"-loop".into(),
|
||||
"1".into(),
|
||||
"-t".into(),
|
||||
format!("{dur:.3}"),
|
||||
"-i".into(),
|
||||
path.clone(),
|
||||
]);
|
||||
}
|
||||
args.extend([
|
||||
"-i".into(),
|
||||
audio_path.into(),
|
||||
"-filter_complex".into(),
|
||||
beat_filtergraph(opts, per_photo),
|
||||
"-map".into(),
|
||||
"[v]".into(),
|
||||
"-map".into(),
|
||||
"[a]".into(),
|
||||
"-t".into(),
|
||||
format!("{total:.3}"),
|
||||
// Force constant frame rate so the beat (and the concatenated reel)
|
||||
// plays at a steady {fps} rather than a variable cadence.
|
||||
"-r".into(),
|
||||
fps,
|
||||
]);
|
||||
args.extend(video_encoder_args(opts.nvenc));
|
||||
args.extend(
|
||||
["-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-shortest"]
|
||||
.iter()
|
||||
.map(|s| s.to_string()),
|
||||
);
|
||||
args.push(out_path.into());
|
||||
args
|
||||
}
|
||||
|
||||
/// Build the concat-demuxer args that join rendered segments losslessly.
|
||||
/// `+faststart` moves the moov atom up front so the reel streams immediately
|
||||
/// on the mobile client. The output muxer is forced with `-f mp4` because we
|
||||
/// write to a `.tmp` path (atomic publish) whose extension ffmpeg can't map to
|
||||
/// a format on its own.
|
||||
pub fn build_concat_args(list_path: &str, out_path: &str) -> Vec<String> {
|
||||
[
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
list_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-f",
|
||||
"mp4",
|
||||
out_path,
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render the concat list file body. Each line points the demuxer at one
|
||||
/// segment; single quotes in paths are escaped per ffmpeg's concat syntax.
|
||||
pub fn build_concat_list(segment_paths: &[String]) -> String {
|
||||
let mut out = String::new();
|
||||
for p in segment_paths {
|
||||
let escaped = p.replace('\'', r"'\''");
|
||||
out.push_str(&format!("file '{escaped}'\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn run_ffmpeg(args: &[String], what: &str) -> Result<()> {
|
||||
let output = Command::new("ffmpeg")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("spawning ffmpeg for {what}"))?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"ffmpeg {what} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render one beat to `out_path`: its photos shown in sequence (a held shot for
|
||||
/// one photo, a quick burst for several) under the single narration in
|
||||
/// `audio_path`, whose measured length sets the beat's pacing.
|
||||
pub async fn render_beat(
|
||||
image_paths: &[std::path::PathBuf],
|
||||
audio_path: &Path,
|
||||
out_path: &Path,
|
||||
narration_secs: f64,
|
||||
opts: &SegmentOpts,
|
||||
) -> Result<()> {
|
||||
if image_paths.is_empty() {
|
||||
bail!("render_beat called with no images");
|
||||
}
|
||||
let (total, per_photo) = beat_durations(narration_secs, image_paths.len());
|
||||
let paths: Vec<String> = image_paths
|
||||
.iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect();
|
||||
let args = build_beat_args(
|
||||
&paths,
|
||||
&audio_path.to_string_lossy(),
|
||||
&out_path.to_string_lossy(),
|
||||
&per_photo,
|
||||
total,
|
||||
opts,
|
||||
);
|
||||
run_ffmpeg(&args, "beat render").await
|
||||
}
|
||||
|
||||
// --- Video-clip beats --------------------------------------------------------
|
||||
|
||||
/// Decide how long the clip plays and how long the whole beat lasts, from the
|
||||
/// source video's length (if known) and the narration length. Returns
|
||||
/// `(clip_dur, beat_total)`.
|
||||
///
|
||||
/// The beat always lasts long enough for the full narration. The clip plays for
|
||||
/// as much of that beat as its footage covers — so the motion fills the screen
|
||||
/// time rather than stopping early. We only freeze the last frame (the
|
||||
/// `beat_total - clip_dur` gap, handled by `tpad` in [`clip_video_filter`]) when
|
||||
/// the source video is genuinely shorter than the narration. Capping clip
|
||||
/// playback at a fixed length while the narration ran longer was what produced
|
||||
/// the second-or-two freeze that read as a glitchy pause before the transition.
|
||||
pub fn clip_beat_plan(source_dur: Option<f64>, narration_secs: f64) -> (f64, f64) {
|
||||
let want = segment_duration(narration_secs);
|
||||
let clip_dur = match source_dur {
|
||||
// Known length: play up to the whole beat, but never past the source.
|
||||
Some(d) if d > 0.0 => d.min(want),
|
||||
// Unknown length: read up to the fallback cap; tpad covers any shortfall.
|
||||
_ => want.min(CLIP_SECONDS),
|
||||
};
|
||||
(clip_dur, want.max(clip_dur))
|
||||
}
|
||||
|
||||
/// Video chain for a clip beat: fill the clip to the portrait canvas (blurred
|
||||
/// backdrop, same look as photos), normalize fps, hold the last frame if the
|
||||
/// narration outlasts the clip (`tpad`), then fade. Produces `[v]`.
|
||||
fn clip_video_filter(opts: &SegmentOpts, clip_dur: f64, beat_total: f64) -> String {
|
||||
let (w, h, fps) = (opts.width, opts.height, opts.fps);
|
||||
let fade = SINGLE_FADE_SECONDS;
|
||||
let hold = (beat_total - clip_dur).max(0.0);
|
||||
let fade_out_start = (beat_total - fade).max(0.0);
|
||||
// Freeze the final frame to cover narration that runs past the clip.
|
||||
let tpad = if hold > 0.05 {
|
||||
format!(",tpad=stop_mode=clone:stop_duration={hold:.3}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"[0:v]split=2[bg][fg];\
|
||||
[bg]scale={w}:{h}:force_original_aspect_ratio=increase,\
|
||||
crop={w}:{h},boxblur=20:2[bgb];\
|
||||
[fg]scale={w}:{h}:force_original_aspect_ratio=decrease[fgs];\
|
||||
[bgb][fgs]overlay=(W-w)/2:(H-h)/2,fps={fps}{tpad},\
|
||||
fade=t=in:st=0:d={fade},fade=t=out:st={fade_out_start:.3}:d={fade},\
|
||||
setsar=1,format=yuv420p[v]"
|
||||
)
|
||||
}
|
||||
|
||||
/// Audio chain for a clip beat. With a clip audio track, duck it under the
|
||||
/// narration and mix; without one, just the narration. Produces `[a]`.
|
||||
fn clip_audio_filter(has_audio: bool) -> String {
|
||||
if has_audio {
|
||||
format!(
|
||||
"[0:a]volume={CLIP_DUCK_VOLUME}[duck];[1:a]apad[narr];\
|
||||
[duck][narr]amix=inputs=2:duration=longest:normalize=0[a]"
|
||||
)
|
||||
} else {
|
||||
"[1:a]apad[a]".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Full `filter_complex` for a clip beat (input 0 = clip, input 1 = narration).
|
||||
pub fn clip_beat_filtergraph(
|
||||
opts: &SegmentOpts,
|
||||
clip_dur: f64,
|
||||
beat_total: f64,
|
||||
has_audio: bool,
|
||||
) -> String {
|
||||
format!(
|
||||
"{};{}",
|
||||
clip_video_filter(opts, clip_dur, beat_total),
|
||||
clip_audio_filter(has_audio)
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the ffmpeg args for a clip beat: the first `clip_dur` seconds of the
|
||||
/// source video, filled to the portrait canvas with its live audio ducked under
|
||||
/// the narration, bounded to `beat_total`.
|
||||
pub fn build_clip_beat_args(
|
||||
clip_path: &str,
|
||||
audio_path: &str,
|
||||
out_path: &str,
|
||||
clip_dur: f64,
|
||||
beat_total: f64,
|
||||
has_audio: bool,
|
||||
opts: &SegmentOpts,
|
||||
) -> Vec<String> {
|
||||
let fps = opts.fps.to_string();
|
||||
let mut args: Vec<String> = vec!["-y".into()];
|
||||
if opts.nvenc {
|
||||
args.extend(["-hwaccel".into(), "cuda".into()]);
|
||||
}
|
||||
args.extend([
|
||||
// Input `-t` limits the clip to its window; audio has none (apad fills).
|
||||
"-t".into(),
|
||||
format!("{clip_dur:.3}"),
|
||||
"-i".into(),
|
||||
clip_path.into(),
|
||||
"-i".into(),
|
||||
audio_path.into(),
|
||||
"-filter_complex".into(),
|
||||
clip_beat_filtergraph(opts, clip_dur, beat_total, has_audio),
|
||||
"-map".into(),
|
||||
"[v]".into(),
|
||||
"-map".into(),
|
||||
"[a]".into(),
|
||||
"-t".into(),
|
||||
format!("{beat_total:.3}"),
|
||||
"-r".into(),
|
||||
fps,
|
||||
]);
|
||||
args.extend(video_encoder_args(opts.nvenc));
|
||||
args.extend(
|
||||
["-c:a", "aac", "-b:a", "160k", "-ar", "48000"]
|
||||
.iter()
|
||||
.map(|s| s.to_string()),
|
||||
);
|
||||
args.push(out_path.into());
|
||||
args
|
||||
}
|
||||
|
||||
/// Whether a media file has at least one audio stream (so a clip beat knows
|
||||
/// whether to mix in live audio). Defaults to `false` on any probe failure.
|
||||
pub async fn has_audio_stream(path: &str) -> bool {
|
||||
Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a",
|
||||
"-show_entries",
|
||||
"stream=index",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
path,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map(|out| !out.stdout.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Render one clip beat: a section of `clip_path` (capped at [`CLIP_SECONDS`],
|
||||
/// and to the source length) under the narration in `audio_path`. The beat
|
||||
/// lasts at least the narration, freezing the clip's last frame if needed.
|
||||
pub async fn render_clip_beat(
|
||||
clip_path: &Path,
|
||||
audio_path: &Path,
|
||||
out_path: &Path,
|
||||
narration_secs: f64,
|
||||
opts: &SegmentOpts,
|
||||
) -> Result<()> {
|
||||
let clip_str = clip_path.to_string_lossy().to_string();
|
||||
// Play the clip for as much of the beat as its footage covers; freeze only
|
||||
// when the source is genuinely shorter than the narration (see clip_beat_plan).
|
||||
let source_dur = crate::video::ffmpeg::get_duration_seconds(&clip_str)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let (clip_dur, beat_total) = clip_beat_plan(source_dur, narration_secs);
|
||||
let has_audio = has_audio_stream(&clip_str).await;
|
||||
|
||||
let args = build_clip_beat_args(
|
||||
&clip_str,
|
||||
&audio_path.to_string_lossy(),
|
||||
&out_path.to_string_lossy(),
|
||||
clip_dur,
|
||||
beat_total,
|
||||
has_audio,
|
||||
opts,
|
||||
);
|
||||
run_ffmpeg(&args, "clip beat render").await
|
||||
}
|
||||
|
||||
/// Join rendered segments into the final reel. Writes the concat list into the
|
||||
/// same directory as the output so relative paths and cleanup stay local.
|
||||
pub async fn concat_segments(segment_paths: &[String], out_path: &Path) -> Result<()> {
|
||||
let list_path = out_path.with_extension("concat.txt");
|
||||
let body = build_concat_list(segment_paths);
|
||||
tokio::fs::write(&list_path, body)
|
||||
.await
|
||||
.context("writing concat list")?;
|
||||
let args = build_concat_args(&list_path.to_string_lossy(), &out_path.to_string_lossy());
|
||||
let result = run_ffmpeg(&args, "concat").await;
|
||||
let _ = tokio::fs::remove_file(&list_path).await;
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn segment_duration_floors_short_lines() {
|
||||
// A one-word narration still lingers at the floor.
|
||||
assert_eq!(segment_duration(0.5), MIN_SEGMENT_SECONDS);
|
||||
assert_eq!(segment_duration(0.0), MIN_SEGMENT_SECONDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_duration_covers_full_narration_plus_tail() {
|
||||
// No ceiling: a long line gets its full length so speech isn't cut.
|
||||
assert!((segment_duration(5.0) - 5.6).abs() < 1e-9);
|
||||
assert!((segment_duration(20.0) - 20.6).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_duration_rejects_nonfinite() {
|
||||
assert_eq!(segment_duration(f64::NAN), MIN_SEGMENT_SECONDS);
|
||||
assert_eq!(segment_duration(f64::INFINITY), MIN_SEGMENT_SECONDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beat_durations_single_photo_matches_base() {
|
||||
let (total, per) = beat_durations(4.0, 1);
|
||||
assert!((total - 4.6).abs() < 1e-9); // narration + tail
|
||||
assert_eq!(per.len(), 1);
|
||||
assert!((per[0] - 4.6).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beat_durations_burst_splits_evenly() {
|
||||
// 5 photos, narration 4.6s base → ~0.92s each (above the 0.6 floor).
|
||||
let (total, per) = beat_durations(4.0, 5);
|
||||
assert!((total - 4.6).abs() < 1e-9);
|
||||
assert_eq!(per.len(), 5);
|
||||
assert!((per.iter().sum::<f64>() - total).abs() < 1e-9);
|
||||
assert!(per.iter().all(|&d| d >= MIN_BURST_PHOTO_SECONDS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beat_durations_stretches_when_narration_too_short_for_burst() {
|
||||
// Floor narration (2.5s) over 10 photos would be 0.25s each — below the
|
||||
// legibility floor, so the beat stretches to 10 × 0.6 = 6s.
|
||||
let (total, per) = beat_durations(0.0, 10);
|
||||
assert!((total - 6.0).abs() < 1e-9);
|
||||
assert!(per.iter().all(|&d| (d - 0.6).abs() < 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beat_filtergraph_single_photo_fills_portrait_and_holds() {
|
||||
let (_t, per) = beat_durations(4.0, 1);
|
||||
let g = beat_filtergraph(&SegmentOpts::default(), &per);
|
||||
assert!(g.contains("[0:v]split=2[bg0][fg0]"));
|
||||
assert!(g.contains("scale=1080:1920:force_original_aspect_ratio=increase"));
|
||||
assert!(g.contains("crop=1080:1920"));
|
||||
assert!(g.contains("scale=1080:1920:force_original_aspect_ratio=decrease"));
|
||||
assert!(g.contains("overlay=(W-w)/2:(H-h)/2"));
|
||||
// Single photo → concat of one, gentle fade, audio is input 1.
|
||||
assert!(g.contains("concat=n=1:v=1:a=0[v]"));
|
||||
assert!(g.contains("d=0.35")); // SINGLE_FADE
|
||||
assert!(g.contains("[1:a]apad[a]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beat_filtergraph_burst_chains_concats_and_snappy_fade() {
|
||||
let (_t, per) = beat_durations(4.0, 3);
|
||||
let g = beat_filtergraph(&SegmentOpts::default(), &per);
|
||||
// One chain per photo with index-suffixed labels.
|
||||
assert!(g.contains("[0:v]split") && g.contains("[1:v]split") && g.contains("[2:v]split"));
|
||||
// Concatenated in order, audio is the 4th input (index 3).
|
||||
assert!(g.contains("[v0][v1][v2]concat=n=3:v=1:a=0[v]"));
|
||||
assert!(g.contains("[3:a]apad[a]"));
|
||||
// Burst uses the much snappier fade (vs 0.35 for a held shot).
|
||||
assert!(g.contains("d=0.12"));
|
||||
assert!(!g.contains("d=0.35"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beat_filtergraph_normalizes_fps_before_fading() {
|
||||
// fps must precede the fades on every chain (else the dip looks steppy).
|
||||
let (_t, per) = beat_durations(4.0, 1);
|
||||
let g = beat_filtergraph(&SegmentOpts::default(), &per);
|
||||
let fps_at = g.find("fps=30").expect("fps in graph");
|
||||
let fade_at = g.find("fade=t=in").expect("fade in graph");
|
||||
assert!(fps_at < fade_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beat_args_one_input_per_photo_plus_audio_bound_by_total() {
|
||||
let (total, per) = beat_durations(4.0, 2);
|
||||
let args = build_beat_args(
|
||||
&["/a.jpg".into(), "/b.jpg".into()],
|
||||
"/n.wav",
|
||||
"/out.mp4",
|
||||
&per,
|
||||
total,
|
||||
&SegmentOpts::default(),
|
||||
);
|
||||
let joined = args.join(" ");
|
||||
// A looped-still input per photo, each with its slice -t, then the audio.
|
||||
assert!(joined.contains("-framerate 30 -loop 1 -t 2.300 -i /a.jpg"));
|
||||
assert!(joined.contains("-framerate 30 -loop 1 -t 2.300 -i /b.jpg"));
|
||||
assert!(joined.contains("-i /n.wav"));
|
||||
// Output bounded to the beat total and forced CFR.
|
||||
assert!(joined.contains("-t 4.600"));
|
||||
assert!(joined.contains("-r 30"));
|
||||
assert!(joined.ends_with("/out.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beat_args_use_nvenc_and_cuda_when_enabled() {
|
||||
let opts = SegmentOpts {
|
||||
nvenc: true,
|
||||
..SegmentOpts::default()
|
||||
};
|
||||
let (total, per) = beat_durations(3.0, 1);
|
||||
let args = build_beat_args(
|
||||
&["/img.jpg".into()],
|
||||
"/a.wav",
|
||||
"/out.mp4",
|
||||
&per,
|
||||
total,
|
||||
&opts,
|
||||
);
|
||||
let joined = args.join(" ");
|
||||
assert!(joined.contains("-hwaccel cuda"));
|
||||
assert!(joined.contains("h264_nvenc"));
|
||||
assert!(!joined.contains("libx264"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_filter_ducks_audio_and_holds_last_frame_when_narration_longer() {
|
||||
// 5s clip, 7s beat → 2s freeze of the last frame, ducked-audio mix.
|
||||
let g = clip_beat_filtergraph(&SegmentOpts::default(), 5.0, 7.0, true);
|
||||
assert!(g.contains("tpad=stop_mode=clone:stop_duration=2.000"));
|
||||
assert!(g.contains("volume=0.35"));
|
||||
assert!(g.contains("amix=inputs=2"));
|
||||
assert!(g.contains("[1:a]apad[narr]"));
|
||||
// Fill applied to the clip too.
|
||||
assert!(g.contains("boxblur"));
|
||||
assert!(g.contains("overlay=(W-w)/2:(H-h)/2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_beat_plan_plays_clip_through_the_whole_beat_when_source_is_long() {
|
||||
// 30s source, 4s narration → beat is narration+tail (4.6), and the clip
|
||||
// plays that whole 4.6s of motion: no freeze (clip_dur == beat_total).
|
||||
let (clip_dur, beat_total) = clip_beat_plan(Some(30.0), 4.0);
|
||||
assert!((beat_total - 4.6).abs() < 1e-9);
|
||||
assert!((clip_dur - 4.6).abs() < 1e-9);
|
||||
assert!((beat_total - clip_dur).abs() < 1e-9); // no hold
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_beat_plan_freezes_only_when_source_shorter_than_narration() {
|
||||
// 2s source under a 4s narration → play all 2s, freeze the remainder.
|
||||
let (clip_dur, beat_total) = clip_beat_plan(Some(2.0), 4.0);
|
||||
assert!((clip_dur - 2.0).abs() < 1e-9);
|
||||
assert!((beat_total - 4.6).abs() < 1e-9);
|
||||
assert!(beat_total - clip_dur > 2.0); // unavoidable freeze gap
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_beat_plan_caps_read_when_source_length_unknown() {
|
||||
// Probe failed: read up to the fallback cap, beat still covers narration.
|
||||
let (clip_dur, beat_total) = clip_beat_plan(None, 8.0);
|
||||
assert!((clip_dur - CLIP_SECONDS).abs() < 1e-9);
|
||||
assert!((beat_total - 8.6).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_filter_no_tpad_when_clip_covers_the_beat() {
|
||||
// Clip at least as long as the beat → no freeze.
|
||||
let g = clip_beat_filtergraph(&SegmentOpts::default(), 5.0, 5.0, true);
|
||||
assert!(!g.contains("tpad"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_filter_narration_only_without_clip_audio() {
|
||||
let g = clip_beat_filtergraph(&SegmentOpts::default(), 5.0, 5.0, false);
|
||||
assert!(!g.contains("amix"));
|
||||
assert!(!g.contains("volume="));
|
||||
assert!(g.contains("[1:a]apad[a]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_beat_args_bound_clip_and_output() {
|
||||
let args = build_clip_beat_args(
|
||||
"/v.mp4",
|
||||
"/n.wav",
|
||||
"/out.mp4",
|
||||
5.0,
|
||||
6.6,
|
||||
true,
|
||||
&SegmentOpts::default(),
|
||||
);
|
||||
let joined = args.join(" ");
|
||||
// Input -t bounds the clip read; output -t bounds the beat.
|
||||
assert!(joined.contains("-t 5.000 -i /v.mp4"));
|
||||
assert!(joined.contains("-i /n.wav"));
|
||||
assert!(joined.contains("-t 6.600"));
|
||||
assert!(joined.contains("-r 30"));
|
||||
assert!(joined.ends_with("/out.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concat_args_stream_copy_with_faststart_and_forced_muxer() {
|
||||
// Output goes to a .tmp path, so the muxer must be forced — ffmpeg
|
||||
// can't infer mp4 from the extension (the bug this guards against).
|
||||
let args = build_concat_args("/tmp/list.txt", "/out.mp4.tmp");
|
||||
let joined = args.join(" ");
|
||||
assert!(joined.contains("-f concat -safe 0 -i /tmp/list.txt"));
|
||||
assert!(joined.contains("-c copy"));
|
||||
assert!(joined.contains("+faststart"));
|
||||
assert!(joined.contains("-f mp4"));
|
||||
// The forced muxer must come before the output path.
|
||||
let f_mp4 = args.windows(2).position(|w| w == ["-f", "mp4"]).unwrap();
|
||||
let out = args.iter().position(|a| a == "/out.mp4.tmp").unwrap();
|
||||
assert!(f_mp4 < out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concat_list_escapes_single_quotes() {
|
||||
let body = build_concat_list(&[
|
||||
"/tmp/seg_000.mp4".into(),
|
||||
"/tmp/own's dir/seg_001.mp4".into(),
|
||||
]);
|
||||
assert!(body.contains("file '/tmp/seg_000.mp4'\n"));
|
||||
// The apostrophe is closed-escaped-reopened per ffmpeg concat syntax.
|
||||
assert!(body.contains(r"own'\''s"));
|
||||
}
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
//! Narration scripting for memory reels.
|
||||
//!
|
||||
//! One LLM call turns the planned beats (each carrying its date and, where
|
||||
//! available, its cached insight) into a short first-person narration line per
|
||||
//! beat plus a title for the reel. A beat may show several photos in a quick
|
||||
//! burst, so a line narrates the *moment*, not a single frame. We reuse the
|
||||
//! cached insight summary as the richest signal rather than re-running vision
|
||||
//! at reel time — that keeps reel generation off the GPU's vision slot.
|
||||
//!
|
||||
//! The prompt builder and response parser are pure so the contract is
|
||||
//! unit-testable; `generate_script` wires them to the LLM client.
|
||||
//!
|
||||
//! The agentic scripter (pre-generation) resolves the backend through the
|
||||
//! InsightGenerator, builds a read-only tool set, and runs a tool loop to
|
||||
//! ground the narration in retrieved context before asking for the final JSON.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{PlannedBeat, ReelMeta};
|
||||
use crate::ai::backend::{BackendKind, SamplingOverrides};
|
||||
use crate::ai::insight_generator::InsightGenerator;
|
||||
use crate::ai::llamacpp::LlamaCppClient;
|
||||
use crate::ai::llm_client::{LlmClient, Tool};
|
||||
use crate::ai::ollama::ChatMessage;
|
||||
|
||||
/// The narration for a whole reel: a title and one line per beat, in order.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ReelScript {
|
||||
pub title: String,
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT: &str = "You are narrating a personal memory reel — a short \
|
||||
slideshow of someone's own photos set to a spoken voiceover. Write warm, \
|
||||
specific, first-person narration as if the person is gently looking back on \
|
||||
their own memories. Each line plays over one moment, which may be a quick burst \
|
||||
of several photos, so narrate the moment as a whole rather than a single frame. \
|
||||
Be concrete and grounded in the details given; never invent names, places, or \
|
||||
events that aren't supported. Keep each line to one or two short sentences that \
|
||||
can be read aloud in a few seconds. Avoid generic filler like \"what a \
|
||||
wonderful day\" — if you have little to go on, simply describe the moment \
|
||||
plainly.";
|
||||
|
||||
/// Agentic scripter system prompt: richer version that tells the model it may
|
||||
/// call read-only tools to ground each line.
|
||||
const AGENTIC_SYSTEM_PROMPT: &str = "You are narrating a personal memory reel — a short \
|
||||
slideshow of someone's own photos set to a spoken voiceover. Write warm, \
|
||||
specific, first-person narration as if the person is gently looking back on \
|
||||
their own memories. Each line plays over one moment, which may be a quick burst \
|
||||
of several photos, so narrate the moment as a whole rather than a single frame. \
|
||||
Be concrete and grounded in the details given; never invent names, places, or \
|
||||
events that aren't supported. Keep each line to one or two short sentences that \
|
||||
can be read aloud in a few seconds. Avoid generic filler like \"what a \
|
||||
wonderful day\" — if you have little to go on, simply describe the moment \
|
||||
plainly.\n\nYou may call read-only tools (search_rag, search_messages, \
|
||||
get_sms_messages, get_calendar_events, get_location_history, reverse_geocode, \
|
||||
get_personal_place_at, recall_entities, get_current_datetime) to ground each \
|
||||
line in real context — e.g. reverse_geocode a moment's GPS to name the place, \
|
||||
or check the calendar/messages around its date. Never invent details. Return \
|
||||
ONLY the JSON object, no prose or code fences.";
|
||||
|
||||
/// Maximum agentic tool iterations for pre-generation. Tunable via
|
||||
/// `REEL_PREGEN_MAX_TOOL_ITERS` (default 8).
|
||||
fn reel_pregen_max_tool_iters() -> usize {
|
||||
std::env::var("REEL_PREGEN_MAX_TOOL_ITERS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.filter(|x| *x > 0)
|
||||
.unwrap_or(8)
|
||||
}
|
||||
|
||||
/// Build the (system, user) prompt pair for the scripter. The user message
|
||||
/// describes each beat in order and asks for strict JSON back.
|
||||
pub fn build_script_messages(meta: &ReelMeta, beats: &[PlannedBeat]) -> (String, String) {
|
||||
let mut user = String::new();
|
||||
user.push_str(&format!(
|
||||
"This reel has {} moments surfaced as memories {}.\n\n",
|
||||
beats.len(),
|
||||
meta.span_phrase()
|
||||
));
|
||||
if !meta.years.is_empty() {
|
||||
let years: Vec<String> = meta.years.iter().map(|y| y.to_string()).collect();
|
||||
user.push_str(&format!("They span the years: {}.\n\n", years.join(", ")));
|
||||
}
|
||||
user.push_str("Moments, in the order they will appear:\n");
|
||||
for (i, beat) in beats.iter().enumerate() {
|
||||
user.push_str(&format!("\n[{}]", i + 1));
|
||||
if let Some(date) = beat.date_label() {
|
||||
user.push_str(&format!(" {date}"));
|
||||
}
|
||||
if beat.is_clip() {
|
||||
user.push_str(" (a video clip)");
|
||||
} else if beat.media.len() > 1 {
|
||||
user.push_str(&format!(" (a burst of {} photos)", beat.media.len()));
|
||||
}
|
||||
user.push('\n');
|
||||
match (&beat.insight_title, &beat.insight_summary) {
|
||||
(Some(t), Some(s)) if !s.trim().is_empty() => {
|
||||
user.push_str(&format!(" Known context: {t} — {s}\n"));
|
||||
}
|
||||
(Some(t), _) => user.push_str(&format!(" Known context: {t}\n")),
|
||||
(_, Some(s)) if !s.trim().is_empty() => {
|
||||
user.push_str(&format!(" Known context: {s}\n"));
|
||||
}
|
||||
_ => user.push_str(" (no extra context — narrate plainly from the date)\n"),
|
||||
}
|
||||
}
|
||||
user.push_str(&format!(
|
||||
"\nReturn ONLY a JSON object, no prose or code fences, shaped exactly:\n\
|
||||
{{\"title\": \"<short reel title>\", \"segments\": [\"<line for moment 1>\", \
|
||||
\"<line for moment 2>\", ... ]}}\n\
|
||||
The \"segments\" array MUST have exactly {} items, one per moment in order.",
|
||||
beats.len()
|
||||
));
|
||||
(SYSTEM_PROMPT.to_string(), user)
|
||||
}
|
||||
|
||||
/// Build a richer (system, user) prompt pair for the agentic scripter. The
|
||||
/// system prompt tells the model it may call read-only tools to ground each
|
||||
/// line. The user message uses the same per-beat enumeration as
|
||||
/// `build_script_messages` plus a GPS line per beat when available.
|
||||
pub fn build_agentic_script_messages(meta: &ReelMeta, beats: &[PlannedBeat]) -> Vec<ChatMessage> {
|
||||
let mut user = String::new();
|
||||
user.push_str(&format!(
|
||||
"This reel has {} moments surfaced as memories {}.\n\n",
|
||||
beats.len(),
|
||||
meta.span_phrase()
|
||||
));
|
||||
if !meta.years.is_empty() {
|
||||
let years: Vec<String> = meta.years.iter().map(|y| y.to_string()).collect();
|
||||
user.push_str(&format!("They span the years: {}.\n\n", years.join(", ")));
|
||||
}
|
||||
user.push_str("Moments, in the order they will appear:\n");
|
||||
for (i, beat) in beats.iter().enumerate() {
|
||||
user.push_str(&format!("\n[{}]", i + 1));
|
||||
if let Some(date) = beat.date_label() {
|
||||
user.push_str(&format!(" {date}"));
|
||||
}
|
||||
if beat.is_clip() {
|
||||
user.push_str(" (a video clip)");
|
||||
} else if beat.media.len() > 1 {
|
||||
user.push_str(&format!(" (a burst of {} photos)", beat.media.len()));
|
||||
}
|
||||
if let Some((lat, lon)) = beat.gps {
|
||||
user.push_str(&format!("\n GPS: {:.4}, {:.4}", lat, lon));
|
||||
}
|
||||
user.push('\n');
|
||||
match (&beat.insight_title, &beat.insight_summary) {
|
||||
(Some(t), Some(s)) if !s.trim().is_empty() => {
|
||||
user.push_str(&format!(" Known context: {t} — {s}\n"));
|
||||
}
|
||||
(Some(t), _) => user.push_str(&format!(" Known context: {t}\n")),
|
||||
(_, Some(s)) if !s.trim().is_empty() => {
|
||||
user.push_str(&format!(" Known context: {s}\n"));
|
||||
}
|
||||
_ => user.push_str(" (no extra context — narrate plainly from the date)\n"),
|
||||
}
|
||||
}
|
||||
user.push_str(&format!(
|
||||
"\nReturn ONLY a JSON object, no prose or code fences, shaped exactly:\n\
|
||||
{{\"title\": \"<short reel title>\", \"segments\": [\"<line for moment 1>\", \
|
||||
\"<line for moment 2>\", ... ]}}\n\
|
||||
The \"segments\" array MUST have exactly {} items, one per moment in order.",
|
||||
beats.len()
|
||||
));
|
||||
|
||||
vec![
|
||||
ChatMessage::system(AGENTIC_SYSTEM_PROMPT.to_string()),
|
||||
ChatMessage::user(user),
|
||||
]
|
||||
}
|
||||
|
||||
/// Parse the model's response into a script with exactly `n` lines. Tolerant of
|
||||
/// code fences and surrounding prose, and of both `segments: [".."]` and
|
||||
/// `segments: [{"narration": ".."}]` shapes. Missing/extra lines are padded or
|
||||
/// truncated so the caller always gets `n` aligned to the segments.
|
||||
pub fn parse_script_response(raw: &str, n: usize) -> ReelScript {
|
||||
let fallback_line = "A moment worth remembering.";
|
||||
let value = extract_json_object(raw);
|
||||
|
||||
let title = value
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("title"))
|
||||
.and_then(|t| t.as_str())
|
||||
.map(clean_text)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "Memories".to_string());
|
||||
|
||||
let mut lines: Vec<String> = value
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("segments"))
|
||||
.and_then(|s| s.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|item| {
|
||||
let text = item
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
item.get("narration")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
clean_text(&text)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Align to exactly n: drop extras, pad shortfalls with a neutral line so
|
||||
// every photo still gets spoken audio.
|
||||
lines.truncate(n);
|
||||
while lines.len() < n {
|
||||
lines.push(fallback_line.to_string());
|
||||
}
|
||||
for line in lines.iter_mut() {
|
||||
if line.is_empty() {
|
||||
*line = fallback_line.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
ReelScript { title, lines }
|
||||
}
|
||||
|
||||
/// Pull the first balanced top-level JSON object out of a possibly-noisy model
|
||||
/// response (code fences, leading prose). Returns None if nothing parses.
|
||||
fn extract_json_object(raw: &str) -> Option<serde_json::Value> {
|
||||
// Fast path: the whole thing is valid JSON.
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw.trim()) {
|
||||
return Some(v);
|
||||
}
|
||||
// Otherwise scan for the first '{' ... matching '}' span, ignoring braces
|
||||
// inside strings.
|
||||
let bytes = raw.as_bytes();
|
||||
let start = raw.find('{')?;
|
||||
let mut depth = 0i32;
|
||||
let mut in_str = false;
|
||||
let mut escaped = false;
|
||||
for i in start..bytes.len() {
|
||||
let c = bytes[i] as char;
|
||||
if in_str {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == '"' {
|
||||
in_str = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' => in_str = true,
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return serde_json::from_str(&raw[start..=i]).ok();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Collapse whitespace and strip stray markdown/quote decorations a model
|
||||
/// sometimes leaves around a line.
|
||||
fn clean_text(s: &str) -> String {
|
||||
let trimmed = s.trim().trim_matches('"').trim();
|
||||
trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Generate the reel script via the LLM. Text-only (no images) — the per-beat
|
||||
/// context comes from cached insights. The call takes the GPU read lease
|
||||
/// internally (see `LlamaCppClient::generate`).
|
||||
pub async fn generate_script(
|
||||
client: &Arc<LlamaCppClient>,
|
||||
meta: &ReelMeta,
|
||||
beats: &[PlannedBeat],
|
||||
) -> Result<ReelScript> {
|
||||
let (system, user) = build_script_messages(meta, beats);
|
||||
let raw = client
|
||||
.generate(&user, Some(&system), None)
|
||||
.await
|
||||
.context("LLM script generation failed")?;
|
||||
Ok(parse_script_response(&raw, beats.len()))
|
||||
}
|
||||
|
||||
/// Agentic version of script generation: resolves the backend via the
|
||||
/// InsightGenerator (honouring LLM_BACKEND, model overrides, etc.), builds
|
||||
/// a read-only tool set, runs the tool loop, then parses the JSON response.
|
||||
/// Returns the same ReelScript shape. On failure the caller may fall back to
|
||||
/// `generate_script`.
|
||||
pub async fn generate_script_agentic(
|
||||
generator: &InsightGenerator,
|
||||
meta: &ReelMeta,
|
||||
beats: &[PlannedBeat],
|
||||
) -> Result<ReelScript> {
|
||||
// 1. Resolve the backend. Bail if the local model lacks tool-calling.
|
||||
let backend = generator
|
||||
.resolve_backend(
|
||||
BackendKind::Local,
|
||||
&SamplingOverrides {
|
||||
model: None,
|
||||
num_ctx: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
top_k: None,
|
||||
min_p: None,
|
||||
enable_thinking: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("resolving backend for agentic script")?;
|
||||
|
||||
// 2. Build the read-only tool set. Start from the persona gate (no
|
||||
// persona context, so corrections are closed), force has_vision=false,
|
||||
// then filter out write tools.
|
||||
let gate = generator.current_gate_opts_for_persona(false, None);
|
||||
let all_tools = InsightGenerator::build_tool_definitions(gate);
|
||||
// Whole-reel calls have no single photo and no authenticated user, so the
|
||||
// loop runs execute_tool with empty file/image context and user_id=0. Only
|
||||
// tools that work without that context are useful here — photo/user-bound
|
||||
// tools (get_file_tags, get_faces_in_photo, recall_facts_for_photo,
|
||||
// recall_facts_for_entity) would just no-op or error, burning iterations,
|
||||
// so they're excluded.
|
||||
let read_only_names: std::collections::HashSet<&str> = [
|
||||
"search_rag",
|
||||
"search_messages",
|
||||
"get_sms_messages",
|
||||
"get_calendar_events",
|
||||
"get_location_history",
|
||||
"reverse_geocode",
|
||||
"get_personal_place_at",
|
||||
"recall_entities",
|
||||
"get_current_datetime",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let tools: Vec<Tool> = all_tools
|
||||
.into_iter()
|
||||
.filter(|t| read_only_names.contains(t.function.name.as_str()))
|
||||
.collect();
|
||||
|
||||
// 3. Build the agentic prompt messages.
|
||||
let messages = build_agentic_script_messages(meta, beats);
|
||||
|
||||
// 4. Run the tool loop.
|
||||
let max_iter = reel_pregen_max_tool_iters();
|
||||
let raw = generator
|
||||
.run_readonly_tool_loop(&backend, messages, tools, max_iter)
|
||||
.await
|
||||
.context("agentic tool loop failed")?;
|
||||
|
||||
// 5. Strip any think-blocks the model may have emitted, then parse.
|
||||
let raw = crate::ai::llm_client::strip_think_blocks(&raw);
|
||||
Ok(parse_script_response(&raw, beats.len()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memories::MemoriesSpan;
|
||||
|
||||
fn meta() -> ReelMeta {
|
||||
ReelMeta {
|
||||
span: MemoriesSpan::Day,
|
||||
years: vec![2019, 2021],
|
||||
}
|
||||
}
|
||||
|
||||
fn planned(n: usize) -> Vec<PlannedBeat> {
|
||||
(0..n)
|
||||
.map(|i| PlannedBeat {
|
||||
media: vec![super::super::SegmentMedia::Photo {
|
||||
rel_path: format!("p{i}.jpg"),
|
||||
library_id: 1,
|
||||
}],
|
||||
date: Some(1_560_000_000 + i as i64 * 86_400),
|
||||
insight_title: None,
|
||||
insight_summary: None,
|
||||
gps: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_states_exact_moment_count_and_span() {
|
||||
let (sys, user) = build_script_messages(&meta(), &planned(3));
|
||||
assert!(sys.contains("memory reel"));
|
||||
assert!(user.contains("3 moments"));
|
||||
assert!(user.contains("on this day"));
|
||||
assert!(user.contains("exactly 3 items"));
|
||||
// Each moment gets an indexed entry.
|
||||
assert!(user.contains("[1]") && user.contains("[2]") && user.contains("[3]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_notes_burst_photo_count() {
|
||||
let mut p = planned(1);
|
||||
p[0].media = vec![
|
||||
super::super::SegmentMedia::Photo {
|
||||
rel_path: "a.jpg".into(),
|
||||
library_id: 1,
|
||||
},
|
||||
super::super::SegmentMedia::Photo {
|
||||
rel_path: "b.jpg".into(),
|
||||
library_id: 1,
|
||||
},
|
||||
super::super::SegmentMedia::Photo {
|
||||
rel_path: "c.jpg".into(),
|
||||
library_id: 1,
|
||||
},
|
||||
];
|
||||
let (_sys, user) = build_script_messages(&meta(), &p);
|
||||
assert!(user.contains("a burst of 3 photos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_marks_clip_beats() {
|
||||
let mut p = planned(1);
|
||||
p[0].media = vec![super::super::SegmentMedia::Clip {
|
||||
rel_path: "v.mp4".into(),
|
||||
library_id: 1,
|
||||
}];
|
||||
let (_sys, user) = build_script_messages(&meta(), &p);
|
||||
assert!(user.contains("a video clip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_includes_insight_context_when_present() {
|
||||
let mut p = planned(1);
|
||||
p[0].insight_title = Some("Lake house weekend".into());
|
||||
p[0].insight_summary = Some("Swimming with the dogs.".into());
|
||||
let (_sys, user) = build_script_messages(&meta(), &p);
|
||||
assert!(user.contains("Lake house weekend — Swimming with the dogs."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_plain_json_object() {
|
||||
let raw = r#"{"title":"Summer Days","segments":["First line.","Second line."]}"#;
|
||||
let script = parse_script_response(raw, 2);
|
||||
assert_eq!(script.title, "Summer Days");
|
||||
assert_eq!(script.lines, vec!["First line.", "Second line."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tolerates_code_fences_and_prose() {
|
||||
let raw = "Sure! Here's your reel:\n```json\n{\"title\": \"Trip\", \"segments\": [\"A.\", \"B.\"]}\n```\nEnjoy!";
|
||||
let script = parse_script_response(raw, 2);
|
||||
assert_eq!(script.title, "Trip");
|
||||
assert_eq!(script.lines, vec!["A.", "B."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_object_segment_shape() {
|
||||
let raw = r#"{"title":"T","segments":[{"narration":"One."},{"narration":"Two."}]}"#;
|
||||
let script = parse_script_response(raw, 2);
|
||||
assert_eq!(script.lines, vec!["One.", "Two."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pads_short_and_truncates_long_to_n() {
|
||||
// Model returned 1 line but we have 3 segments → pad with neutral lines.
|
||||
let short = parse_script_response(r#"{"title":"T","segments":["Only one."]}"#, 3);
|
||||
assert_eq!(short.lines.len(), 3);
|
||||
assert_eq!(short.lines[0], "Only one.");
|
||||
assert!(!short.lines[1].is_empty());
|
||||
|
||||
// Model returned 3 but we have 2 → truncate.
|
||||
let long = parse_script_response(r#"{"title":"T","segments":["a","b","c"]}"#, 2);
|
||||
assert_eq!(long.lines, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_falls_back_on_garbage() {
|
||||
let script = parse_script_response("the model said no", 2);
|
||||
assert_eq!(script.title, "Memories");
|
||||
assert_eq!(script.lines.len(), 2);
|
||||
assert!(script.lines.iter().all(|l| !l.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_blank_line_replaced_with_fallback() {
|
||||
let script = parse_script_response(r#"{"title":"T","segments":[" ","Real."]}"#, 2);
|
||||
assert!(!script.lines[0].is_empty());
|
||||
assert_eq!(script.lines[1], "Real.");
|
||||
}
|
||||
}
|
||||
@@ -1,560 +0,0 @@
|
||||
//! Reel selectors: resolve "what goes in the reel" into an ordered media set
|
||||
//! plus the metadata the scripter needs. The renderer and scripter are
|
||||
//! selector-agnostic, so adding tag- or date-range-based reels later means
|
||||
//! adding a variant here, not touching the pipeline.
|
||||
//!
|
||||
//! Resolution is split in two so the handler can compute a cache key (and
|
||||
//! short-circuit on a cache hit) without the per-photo insight lookups:
|
||||
//! [`resolve`] is the cheap media-set pass; [`enrich`] adds cached insights and
|
||||
//! runs in the background job.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use chrono::{DateTime, Datelike, FixedOffset};
|
||||
|
||||
use super::{PlannedBeat, ReelMeta, SegmentMedia};
|
||||
use crate::database::{ExifDao, InsightDao};
|
||||
use crate::file_types::{is_image_file, is_video_file};
|
||||
use crate::memories::{self, MemoriesSpan};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Default and hard caps on how many photos a reel covers. The default is an
|
||||
/// upper bound on the request; the effective count is usually smaller, set by
|
||||
/// the duration budget (see [`budget_segments`]). The hard cap bounds work per
|
||||
/// reel regardless.
|
||||
pub const DEFAULT_MAX_SEGMENTS: usize = 40;
|
||||
pub const HARD_MAX_SEGMENTS: usize = 40;
|
||||
|
||||
/// Target reel length. Week and especially month spans can surface hundreds of
|
||||
/// photos; at a few seconds of narration each, a naive reel runs minutes. We
|
||||
/// cap the segment count to keep the reel near this length. Tunable via
|
||||
/// `REEL_TARGET_SECONDS`.
|
||||
const DEFAULT_TARGET_REEL_SECONDS: f64 = 90.0;
|
||||
|
||||
/// Rough average wall-time per photo segment (a short narration line + the
|
||||
/// silent tail). Only used to turn the duration target into a segment count;
|
||||
/// the real per-segment time is the measured narration length.
|
||||
const EST_SECONDS_PER_SEGMENT: f64 = 5.0;
|
||||
|
||||
/// Time gap that separates one "event/moment" from the next when clustering a
|
||||
/// span's photos. Photos within a few hours are treated as the same occasion
|
||||
/// (and across years/days the gaps are far larger, so each instance clusters
|
||||
/// on its own). 4 hours splits e.g. a morning hike from an evening dinner.
|
||||
const EVENT_GAP_SECONDS: i64 = 4 * 3600;
|
||||
|
||||
fn target_reel_seconds() -> f64 {
|
||||
std::env::var("REEL_TARGET_SECONDS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<f64>().ok())
|
||||
.filter(|x| x.is_finite() && *x > 0.0)
|
||||
.unwrap_or(DEFAULT_TARGET_REEL_SECONDS)
|
||||
}
|
||||
|
||||
/// How many photo segments fit the duration budget, bounded by the request's
|
||||
/// max and the hard cap. This is what keeps week/month reels from running long.
|
||||
pub fn budget_segments(requested_max: usize) -> usize {
|
||||
let by_budget = (target_reel_seconds() / EST_SECONDS_PER_SEGMENT).floor() as usize;
|
||||
by_budget.min(requested_max).clamp(1, HARD_MAX_SEGMENTS)
|
||||
}
|
||||
|
||||
/// What a reel is built from. v1 ships the memories (on this day/week/month)
|
||||
/// selector; tag and date-range variants slot in here later.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ReelSelector {
|
||||
Memories {
|
||||
span: MemoriesSpan,
|
||||
tz_offset_minutes: i32,
|
||||
library: Option<String>,
|
||||
max_segments: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl ReelSelector {
|
||||
/// Stable string identity for the cache key. Captures everything that
|
||||
/// changes *which* media is selected (but not the non-deterministic
|
||||
/// narration, which can't be part of a pre-render key).
|
||||
pub fn descriptor(&self) -> String {
|
||||
match self {
|
||||
ReelSelector::Memories {
|
||||
span,
|
||||
tz_offset_minutes,
|
||||
library,
|
||||
max_segments,
|
||||
} => format!(
|
||||
"memories:span={:?}:tz={}:lib={}:max={}",
|
||||
span,
|
||||
tz_offset_minutes,
|
||||
library.as_deref().unwrap_or("all"),
|
||||
max_segments
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick at most `max` items spread evenly across the input, always keeping the
|
||||
/// first and last. Returns the input unchanged when it already fits.
|
||||
pub fn sample_evenly<T: Clone>(items: &[T], max: usize) -> Vec<T> {
|
||||
if max == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
if items.len() <= max {
|
||||
return items.to_vec();
|
||||
}
|
||||
if max == 1 {
|
||||
return vec![items[0].clone()];
|
||||
}
|
||||
let last = items.len() - 1;
|
||||
(0..max)
|
||||
.map(|i| {
|
||||
// Spread indices 0..=last across max picks, endpoints included.
|
||||
let idx = (i * last + (max - 1) / 2) / (max - 1);
|
||||
items[idx.min(last)].clone()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Group time-sorted items into events by gap: a new event starts whenever the
|
||||
/// jump from the previous photo exceeds `gap_seconds`. Preserves order; items
|
||||
/// without a timestamp extend the current event.
|
||||
fn cluster_by_gap(
|
||||
items: &[memories::MemoryItem],
|
||||
gap_seconds: i64,
|
||||
) -> Vec<Vec<memories::MemoryItem>> {
|
||||
let mut clusters: Vec<Vec<memories::MemoryItem>> = Vec::new();
|
||||
let mut prev_ts: Option<i64> = None;
|
||||
for it in items {
|
||||
let starts_new = match (prev_ts, it.created) {
|
||||
(Some(p), Some(c)) => c - p > gap_seconds,
|
||||
_ => false,
|
||||
};
|
||||
if starts_new || clusters.is_empty() {
|
||||
clusters.push(Vec::new());
|
||||
}
|
||||
clusters.last_mut().unwrap().push(it.clone());
|
||||
if let Some(c) = it.created {
|
||||
prev_ts = Some(c);
|
||||
}
|
||||
}
|
||||
clusters
|
||||
}
|
||||
|
||||
/// Most photos a single beat will flash through. Bounds the burst so one huge
|
||||
/// event doesn't dominate, and keeps each photo on screen long enough to
|
||||
/// register at the per-beat narration length (see render's beat timing).
|
||||
pub const MAX_BURST_PHOTOS: usize = 10;
|
||||
|
||||
/// Merge a list of (time-ordered) event clusters into exactly `n` contiguous
|
||||
/// groups, so a span with more events than the beat budget still covers the
|
||||
/// whole timeline — adjacent events fold together into one beat rather than
|
||||
/// getting dropped. `n` must be ≥ 1 and ≤ clusters.len().
|
||||
fn partition_into_groups(
|
||||
clusters: Vec<Vec<memories::MemoryItem>>,
|
||||
n: usize,
|
||||
) -> Vec<Vec<memories::MemoryItem>> {
|
||||
let c = clusters.len();
|
||||
let mut clusters = clusters.into_iter();
|
||||
(0..n)
|
||||
.map(|j| {
|
||||
// Even contiguous split of c clusters into n groups.
|
||||
let start = j * c / n;
|
||||
let end = (j + 1) * c / n;
|
||||
let take = end.saturating_sub(start).max(1);
|
||||
(0..take)
|
||||
.flat_map(|_| clusters.next().into_iter().flatten())
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Turn photo items into `n_beats` photo beats. Clusters photos into events by
|
||||
/// time gap; if there are more events than beats, adjacent events are merged so
|
||||
/// the whole span is still covered. Each beat then flashes up to `max_burst`
|
||||
/// photos (an even spread of its group) under one narration line — so a
|
||||
/// week/month reel *shows* all its moments without a narrated (and timed)
|
||||
/// segment per photo.
|
||||
fn form_photo_beats(
|
||||
items: &[memories::MemoryItem],
|
||||
n_beats: usize,
|
||||
max_burst: usize,
|
||||
) -> Vec<PlannedBeat> {
|
||||
if n_beats == 0 || items.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let clusters = cluster_by_gap(items, EVENT_GAP_SECONDS);
|
||||
// One beat per event when they fit; otherwise fold adjacent events together
|
||||
// into exactly n_beats groups.
|
||||
let groups = if clusters.len() <= n_beats {
|
||||
clusters
|
||||
} else {
|
||||
partition_into_groups(clusters, n_beats)
|
||||
};
|
||||
|
||||
groups
|
||||
.into_iter()
|
||||
.filter(|g| !g.is_empty())
|
||||
.map(|group| {
|
||||
let shown = sample_evenly(&group, max_burst);
|
||||
let date = shown.first().and_then(|it| it.created);
|
||||
PlannedBeat {
|
||||
media: shown
|
||||
.into_iter()
|
||||
.map(|it| SegmentMedia::Photo {
|
||||
rel_path: it.path,
|
||||
library_id: it.library_id,
|
||||
})
|
||||
.collect(),
|
||||
date,
|
||||
insight_title: None,
|
||||
insight_summary: None,
|
||||
gps: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Split the beat budget between photo beats and video-clip beats. Clips are
|
||||
/// individually valuable (motion + live audio) so they get up to half the
|
||||
/// budget (at least one if any exist); photos take the rest. With only one
|
||||
/// kind present, it gets the whole budget.
|
||||
fn split_beat_budget(n_photos: usize, n_videos: usize, n_beats: usize) -> (usize, usize) {
|
||||
if n_videos == 0 {
|
||||
return (n_beats, 0);
|
||||
}
|
||||
if n_photos == 0 {
|
||||
return (0, n_beats.min(n_videos));
|
||||
}
|
||||
let clip_beats = n_videos.min((n_beats / 2).max(1));
|
||||
let photo_beats = n_beats.saturating_sub(clip_beats);
|
||||
(photo_beats, clip_beats)
|
||||
}
|
||||
|
||||
/// Build the reel's beats from a span's photos and videos under a beat budget.
|
||||
/// Videos become one-clip beats (sampled across time if there are more than the
|
||||
/// clip budget); photos cluster into burst beats. The two are merged back into
|
||||
/// chronological order so the reel reads as the span unfolded.
|
||||
pub fn form_beats(
|
||||
photos: &[memories::MemoryItem],
|
||||
videos: &[memories::MemoryItem],
|
||||
n_beats: usize,
|
||||
max_burst: usize,
|
||||
) -> Vec<PlannedBeat> {
|
||||
if n_beats == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let (photo_budget, clip_budget) = split_beat_budget(photos.len(), videos.len(), n_beats);
|
||||
|
||||
let mut beats = form_photo_beats(photos, photo_budget, max_burst);
|
||||
|
||||
// One clip beat per chosen video, spread across the span's videos.
|
||||
for v in sample_evenly(videos, clip_budget) {
|
||||
beats.push(PlannedBeat {
|
||||
media: vec![SegmentMedia::Clip {
|
||||
rel_path: v.path,
|
||||
library_id: v.library_id,
|
||||
}],
|
||||
date: v.created,
|
||||
insight_title: None,
|
||||
insight_summary: None,
|
||||
gps: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge photo and clip beats back into chronological order (undated last).
|
||||
beats.sort_by(|a, b| match (a.date, b.date) {
|
||||
(Some(x), Some(y)) => x.cmp(&y),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
});
|
||||
beats
|
||||
}
|
||||
|
||||
/// Cheap pass: resolve the selector into an ordered list of media (no insight
|
||||
/// lookups yet) plus reel metadata. `Err` only on an invalid library param.
|
||||
pub fn resolve(
|
||||
app_state: &AppState,
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
span_context: &opentelemetry::Context,
|
||||
selector: &ReelSelector,
|
||||
) -> Result<(Vec<PlannedBeat>, ReelMeta), String> {
|
||||
match selector {
|
||||
ReelSelector::Memories {
|
||||
span,
|
||||
tz_offset_minutes,
|
||||
library,
|
||||
max_segments,
|
||||
} => {
|
||||
let client_tz = FixedOffset::east_opt(tz_offset_minutes * 60);
|
||||
let items = memories::gather_memory_items(
|
||||
app_state,
|
||||
exif_dao,
|
||||
span_context,
|
||||
*span,
|
||||
*tz_offset_minutes,
|
||||
client_tz,
|
||||
library.as_deref(),
|
||||
)?;
|
||||
|
||||
// Split into photos and video clips; anything that's neither is
|
||||
// dropped. Years span both, computed before the budget narrows it.
|
||||
let years = distinct_years(&items, client_tz);
|
||||
let meta = ReelMeta { span: *span, years };
|
||||
|
||||
let (photos, videos): (Vec<_>, Vec<_>) = items
|
||||
.into_iter()
|
||||
.filter(|it| {
|
||||
is_image_file(Path::new(&it.path)) || is_video_file(Path::new(&it.path))
|
||||
})
|
||||
.partition(|it| is_image_file(Path::new(&it.path)));
|
||||
|
||||
// The budget caps the number of narrated beats (≈ reel length);
|
||||
// photo beats then burst through several photos and video beats
|
||||
// play a short clip, so the reel covers the span without running
|
||||
// minutes long.
|
||||
let n_beats = budget_segments(*max_segments);
|
||||
let beats = form_beats(&photos, &videos, n_beats, MAX_BURST_PHOTOS);
|
||||
Ok((beats, meta))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinct calendar years represented by the selected media, in the client's
|
||||
/// timezone, ascending. Used to tell the scripter how far back the reel reaches.
|
||||
fn distinct_years(items: &[memories::MemoryItem], tz: Option<FixedOffset>) -> Vec<i32> {
|
||||
let mut years: Vec<i32> = items
|
||||
.iter()
|
||||
.filter_map(|it| it.created)
|
||||
.filter_map(|ts| DateTime::from_timestamp(ts, 0))
|
||||
.map(|dt| match tz {
|
||||
Some(off) => dt.with_timezone(&off).year(),
|
||||
None => dt.year(),
|
||||
})
|
||||
.collect();
|
||||
years.sort_unstable();
|
||||
years.dedup();
|
||||
years
|
||||
}
|
||||
|
||||
/// Background pass: fill each beat's cached insight (title + summary) and
|
||||
/// GPS coordinates from its lead photo, where one exists. Best-effort — a
|
||||
/// missing or errored lookup leaves the fields `None` and the scripter
|
||||
/// narrates from the date alone.
|
||||
pub fn enrich(
|
||||
insight_dao: &Mutex<Box<dyn InsightDao>>,
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
span_context: &opentelemetry::Context,
|
||||
beats: &mut [PlannedBeat],
|
||||
) {
|
||||
let Ok(mut insight_dao) = insight_dao.lock() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut exif_dao) = exif_dao.lock() else {
|
||||
return;
|
||||
};
|
||||
for beat in beats.iter_mut() {
|
||||
let rel_path = match beat.media.first() {
|
||||
Some(SegmentMedia::Photo { rel_path, .. } | SegmentMedia::Clip { rel_path, .. }) => {
|
||||
rel_path.clone()
|
||||
}
|
||||
None => continue,
|
||||
};
|
||||
if let Ok(Some(insight)) = insight_dao.get_insight(span_context, &rel_path) {
|
||||
beat.insight_title = Some(insight.title);
|
||||
beat.insight_summary = Some(insight.summary);
|
||||
}
|
||||
// Enrich GPS from EXIF when the lead media is a photo.
|
||||
if let Some(SegmentMedia::Photo { .. }) = beat.media.first()
|
||||
&& let Ok(Some(exif)) = exif_dao.get_exif(span_context, &rel_path)
|
||||
&& let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude)
|
||||
{
|
||||
beat.gps = Some((lat as f64, lon as f64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sample_evenly_returns_all_when_under_cap() {
|
||||
let v = vec![1, 2, 3];
|
||||
assert_eq!(sample_evenly(&v, 5), vec![1, 2, 3]);
|
||||
assert_eq!(sample_evenly(&v, 3), vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_evenly_keeps_endpoints_and_spreads() {
|
||||
let v: Vec<i32> = (0..100).collect();
|
||||
let picked = sample_evenly(&v, 5);
|
||||
assert_eq!(picked.len(), 5);
|
||||
assert_eq!(picked[0], 0); // first kept
|
||||
assert_eq!(*picked.last().unwrap(), 99); // last kept
|
||||
// Strictly increasing, no dupes.
|
||||
assert!(picked.windows(2).all(|w| w[0] < w[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_evenly_handles_one_and_zero() {
|
||||
let v: Vec<i32> = (0..10).collect();
|
||||
assert_eq!(sample_evenly(&v, 1), vec![0]);
|
||||
assert!(sample_evenly(&v, 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_is_stable_and_distinguishes_inputs() {
|
||||
let a = ReelSelector::Memories {
|
||||
span: MemoriesSpan::Day,
|
||||
tz_offset_minutes: -480,
|
||||
library: None,
|
||||
max_segments: 24,
|
||||
};
|
||||
let b = ReelSelector::Memories {
|
||||
span: MemoriesSpan::Week,
|
||||
tz_offset_minutes: -480,
|
||||
library: None,
|
||||
max_segments: 24,
|
||||
};
|
||||
assert_eq!(a.descriptor(), a.clone().descriptor());
|
||||
assert_ne!(a.descriptor(), b.descriptor());
|
||||
assert!(a.descriptor().contains("lib=all"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_years_dedupes_and_sorts() {
|
||||
let items = vec![
|
||||
memories::MemoryItem {
|
||||
path: "a".into(),
|
||||
created: Some(1_560_000_000), // 2019
|
||||
modified: None,
|
||||
library_id: 1,
|
||||
},
|
||||
memories::MemoryItem {
|
||||
path: "b".into(),
|
||||
created: Some(1_560_086_400), // 2019
|
||||
modified: None,
|
||||
library_id: 1,
|
||||
},
|
||||
memories::MemoryItem {
|
||||
path: "c".into(),
|
||||
created: Some(1_623_000_000), // 2021
|
||||
modified: None,
|
||||
library_id: 1,
|
||||
},
|
||||
];
|
||||
assert_eq!(distinct_years(&items, None), vec![2019, 2021]);
|
||||
}
|
||||
|
||||
// Build an item at a given unix timestamp (seconds) with a chosen extension.
|
||||
fn item_ext(ts: i64, name: &str, ext: &str) -> memories::MemoryItem {
|
||||
memories::MemoryItem {
|
||||
path: format!("{name}.{ext}"),
|
||||
created: Some(ts),
|
||||
modified: None,
|
||||
library_id: 1,
|
||||
}
|
||||
}
|
||||
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
|
||||
item_ext(ts, name, "jpg")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_segments_caps_to_duration_target() {
|
||||
// 90s / 5s ≈ 18, bounded by the request max and hard cap.
|
||||
assert_eq!(budget_segments(40), 18);
|
||||
assert_eq!(budget_segments(5), 5); // request asked for fewer
|
||||
assert_eq!(budget_segments(1000), 18); // hard cap / budget wins
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_by_gap_splits_on_large_jumps() {
|
||||
// Two photos minutes apart, then one a day later → two events.
|
||||
let items = vec![
|
||||
item_at(1_000_000, "a"),
|
||||
item_at(1_000_300, "b"), // +5 min → same event
|
||||
item_at(1_100_000, "c"), // +~27h → new event
|
||||
];
|
||||
let clusters = cluster_by_gap(&items, EVENT_GAP_SECONDS);
|
||||
assert_eq!(clusters.len(), 2);
|
||||
assert_eq!(clusters[0].len(), 2);
|
||||
assert_eq!(clusters[1].len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_beats_one_per_event_when_they_fit() {
|
||||
// Three well-separated events, budget of 10 → three beats, each holding
|
||||
// all of its (few) photos.
|
||||
let items = vec![
|
||||
item_at(0, "a"),
|
||||
item_at(50, "b"), // same event as a
|
||||
item_at(1_000_000, "c"),
|
||||
item_at(2_000_000, "d"),
|
||||
];
|
||||
let beats = form_photo_beats(&items, 10, MAX_BURST_PHOTOS);
|
||||
assert_eq!(beats.len(), 3);
|
||||
assert_eq!(beats[0].media.len(), 2); // burst of the first event
|
||||
assert_eq!(beats[1].media.len(), 1);
|
||||
assert_eq!(beats[2].media.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_beats_merge_events_when_over_budget() {
|
||||
// Six distinct events but only two beats → adjacent events fold in, and
|
||||
// every event's photos still appear (capped by the burst max).
|
||||
let items: Vec<memories::MemoryItem> = (0..6)
|
||||
.map(|i| item_at(i as i64 * 1_000_000, &format!("e{i}")))
|
||||
.collect();
|
||||
let beats = form_photo_beats(&items, 2, MAX_BURST_PHOTOS);
|
||||
assert_eq!(beats.len(), 2);
|
||||
let shown: usize = beats.iter().map(|b| b.media.len()).sum();
|
||||
assert_eq!(shown, 6); // all six moments still shown across two beats
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_beats_cap_burst_to_max() {
|
||||
// One dense event of 30 photos, generous budget → a single beat that
|
||||
// bursts at most MAX_BURST_PHOTOS, not all 30.
|
||||
let items: Vec<memories::MemoryItem> = (0..30)
|
||||
.map(|i| item_at(i as i64, &format!("p{i}")))
|
||||
.collect();
|
||||
let beats = form_photo_beats(&items, 18, MAX_BURST_PHOTOS);
|
||||
assert_eq!(beats.len(), 1);
|
||||
assert_eq!(beats[0].media.len(), MAX_BURST_PHOTOS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_beat_budget_handles_each_mix() {
|
||||
// Only photos / only videos → that kind gets the whole budget.
|
||||
assert_eq!(split_beat_budget(10, 0, 18), (18, 0));
|
||||
assert_eq!(split_beat_budget(0, 10, 18), (0, 10)); // capped at n_videos
|
||||
assert_eq!(split_beat_budget(0, 30, 18), (0, 18)); // capped at budget
|
||||
// Mixed → clips up to half (≥1), photos the rest.
|
||||
assert_eq!(split_beat_budget(100, 100, 18), (9, 9));
|
||||
assert_eq!(split_beat_budget(100, 1, 18), (17, 1)); // few videos
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_beats_mixes_clip_and_photo_beats_in_time_order() {
|
||||
let photos = vec![item_at(0, "p0"), item_at(2_000_000, "p1")];
|
||||
// A video between the two photo events (in time).
|
||||
let videos = vec![item_ext(1_000_000, "v0", "mp4")];
|
||||
let beats = form_beats(&photos, &videos, 10, MAX_BURST_PHOTOS);
|
||||
// Two photo events + one clip = three beats, chronological.
|
||||
assert_eq!(beats.len(), 3);
|
||||
assert!(!beats[0].is_clip()); // p0 @ t=0
|
||||
assert!(beats[1].is_clip()); // v0 @ t=1e6
|
||||
assert!(!beats[2].is_clip()); // p1 @ t=2e6
|
||||
assert!(matches!(beats[1].media[0], SegmentMedia::Clip { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_beats_videos_only_become_clip_beats() {
|
||||
let videos: Vec<memories::MemoryItem> = (0..3)
|
||||
.map(|i| item_ext(i as i64 * 1_000_000, &format!("v{i}"), "mov"))
|
||||
.collect();
|
||||
let beats = form_beats(&[], &videos, 10, MAX_BURST_PHOTOS);
|
||||
assert_eq!(beats.len(), 3);
|
||||
assert!(beats.iter().all(|b| b.is_clip()));
|
||||
}
|
||||
}
|
||||
+28
-168
@@ -1,17 +1,13 @@
|
||||
use crate::ai::apollo_client::ApolloClient;
|
||||
use crate::ai::clip_client::ClipClient;
|
||||
use crate::ai::face_client::FaceClient;
|
||||
use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
|
||||
use crate::ai::llamacpp::LlamaCppClient;
|
||||
use crate::ai::openrouter::OpenRouterClient;
|
||||
use crate::ai::turn_registry::TurnRegistry;
|
||||
use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient};
|
||||
use crate::database::{
|
||||
CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, InsightGenerationJobDao, KnowledgeDao,
|
||||
LocationHistoryDao, PrecomputedReelDao, SearchHistoryDao, SqliteCalendarEventDao,
|
||||
SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao, SqliteInsightGenerationJobDao,
|
||||
SqliteKnowledgeDao, SqliteLocationHistoryDao, SqlitePrecomputedReelDao, SqliteSearchHistoryDao,
|
||||
SqliteUserAiPrefsDao, UserAiPrefsDao, connect,
|
||||
CalendarEventDao, DailySummaryDao, ExifDao, InsightDao, KnowledgeDao, LocationHistoryDao,
|
||||
SearchHistoryDao, SqliteCalendarEventDao, SqliteDailySummaryDao, SqliteExifDao,
|
||||
SqliteInsightDao, SqliteKnowledgeDao, SqliteLocationHistoryDao, SqliteSearchHistoryDao,
|
||||
connect,
|
||||
};
|
||||
use crate::database::{PreviewDao, SqlitePreviewDao};
|
||||
use crate::faces;
|
||||
@@ -21,7 +17,6 @@ use crate::video::actors::{
|
||||
PlaylistGenerator, PreviewClipGenerator, StreamActor, VideoPlaylistManager,
|
||||
};
|
||||
use actix::{Actor, Addr};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
@@ -54,10 +49,6 @@ pub struct AppState {
|
||||
pub video_path: String,
|
||||
pub gif_path: String,
|
||||
pub preview_clips_path: String,
|
||||
/// Directory for cached memory-reel MP4s (+ title sidecars). Derived from
|
||||
/// `REELS_DIRECTORY`, defaulting to a `reels` dir beside the preview clips.
|
||||
/// Created lazily by the reel pipeline on first render.
|
||||
pub reels_path: String,
|
||||
pub excluded_dirs: Vec<String>,
|
||||
pub ollama: OllamaClient,
|
||||
/// `None` when `OPENROUTER_API_KEY` is not configured. Consulted only
|
||||
@@ -70,33 +61,15 @@ pub struct AppState {
|
||||
/// Curated list of OpenRouter model ids exposed to clients. Sourced from
|
||||
/// `OPENROUTER_ALLOWED_MODELS` (comma-separated). Empty when unset.
|
||||
pub openrouter_allowed_models: Vec<String>,
|
||||
/// `None` when `LLAMA_SWAP_URL` is not configured. Consulted only when a
|
||||
/// request explicitly opts into `backend=llamacpp`. Same shape as the
|
||||
/// `openrouter` slot — present here so handlers can route to it without
|
||||
/// threading through the generator.
|
||||
#[allow(dead_code)]
|
||||
pub llamacpp: Option<Arc<LlamaCppClient>>,
|
||||
/// Curated list of llama-swap model ids exposed to clients. Sourced from
|
||||
/// `LLAMA_SWAP_ALLOWED_MODELS` (comma-separated). Empty when unset; the
|
||||
/// server then falls back to `LLAMA_SWAP_PRIMARY_MODEL`.
|
||||
pub llamacpp_allowed_models: Vec<String>,
|
||||
pub sms_client: SmsApiClient,
|
||||
pub insight_generator: InsightGenerator,
|
||||
/// Chat continuation service. Hold an Arc so handlers can clone cheaply.
|
||||
pub insight_chat: Arc<InsightChatService>,
|
||||
pub turn_registry: Arc<TurnRegistry>,
|
||||
/// Face inference client (calls Apollo's `/api/internal/faces/*`).
|
||||
/// Disabled (`is_enabled() == false`) when neither `APOLLO_FACE_API_BASE_URL`
|
||||
/// nor `APOLLO_API_BASE_URL` is set; the file-watch hook (Phase 3) and
|
||||
/// manual-face-create handler short-circuit in that case.
|
||||
pub face_client: FaceClient,
|
||||
pub clip_client: ClipClient,
|
||||
pub insight_job_dao: Arc<Mutex<Box<dyn InsightGenerationJobDao>>>,
|
||||
pub insight_job_handles: Arc<Mutex<HashMap<i32, tokio::task::AbortHandle>>>,
|
||||
/// Ledger for precomputed memory reels. Written by the nightly agentic
|
||||
/// job (Section D); read by `GET /reels/precomputed` (Section C).
|
||||
#[allow(dead_code)]
|
||||
pub precomputed_reel_dao: Arc<Mutex<Box<dyn PrecomputedReelDao>>>,
|
||||
/// User AI preferences (voice, timezone, library). Mirrored by the
|
||||
/// client; read by the nightly pre-generation scheduler.
|
||||
#[allow(dead_code)]
|
||||
pub user_ai_prefs_dao: Arc<Mutex<Box<dyn UserAiPrefsDao>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -110,7 +83,6 @@ impl AppState {
|
||||
self.libraries.iter().find(|l| l.id == id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn library_by_name(&self, name: &str) -> Option<&Library> {
|
||||
self.libraries.iter().find(|l| l.name == name)
|
||||
}
|
||||
@@ -128,26 +100,18 @@ impl AppState {
|
||||
ollama: OllamaClient,
|
||||
openrouter: Option<Arc<OpenRouterClient>>,
|
||||
openrouter_allowed_models: Vec<String>,
|
||||
llamacpp: Option<Arc<LlamaCppClient>>,
|
||||
llamacpp_allowed_models: Vec<String>,
|
||||
sms_client: SmsApiClient,
|
||||
insight_generator: InsightGenerator,
|
||||
insight_chat: Arc<InsightChatService>,
|
||||
turn_registry: Arc<TurnRegistry>,
|
||||
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
|
||||
face_client: FaceClient,
|
||||
clip_client: ClipClient,
|
||||
insight_job_dao: Arc<Mutex<Box<dyn InsightGenerationJobDao>>>,
|
||||
insight_job_handles: Arc<Mutex<HashMap<i32, tokio::task::AbortHandle>>>,
|
||||
precomputed_reel_dao: Arc<Mutex<Box<dyn PrecomputedReelDao>>>,
|
||||
user_ai_prefs_dao: Arc<Mutex<Box<dyn UserAiPrefsDao>>>,
|
||||
) -> Self {
|
||||
assert!(
|
||||
!libraries_vec.is_empty(),
|
||||
"AppState::new requires at least one library"
|
||||
);
|
||||
let base_path = libraries_vec[0].root_path.clone();
|
||||
let playlist_generator = PlaylistGenerator::new(video_path.clone());
|
||||
let playlist_generator = PlaylistGenerator::new();
|
||||
let video_playlist_manager =
|
||||
VideoPlaylistManager::new(video_path.clone(), playlist_generator.start());
|
||||
|
||||
@@ -157,19 +121,6 @@ impl AppState {
|
||||
preview_dao,
|
||||
);
|
||||
|
||||
// Reels cache dir: explicit env, else a `reels` sibling of the preview
|
||||
// clips dir (a known-writable, test-safe location). Not created here —
|
||||
// the reel pipeline does `create_dir_all` before its first write, so
|
||||
// construction (incl. tests) never touches the filesystem.
|
||||
let reels_path = std::env::var("REELS_DIRECTORY").unwrap_or_else(|_| {
|
||||
std::path::Path::new(&preview_clips_path)
|
||||
.parent()
|
||||
.map(|p| p.join("reels"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("reels"))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let library_health = libraries::new_health_map(&libraries_vec);
|
||||
let live_libraries = Arc::new(RwLock::new(libraries_vec.clone()));
|
||||
Self {
|
||||
@@ -184,23 +135,14 @@ impl AppState {
|
||||
video_path,
|
||||
gif_path,
|
||||
preview_clips_path,
|
||||
reels_path,
|
||||
excluded_dirs,
|
||||
ollama,
|
||||
openrouter,
|
||||
openrouter_allowed_models,
|
||||
llamacpp,
|
||||
llamacpp_allowed_models,
|
||||
sms_client,
|
||||
insight_generator,
|
||||
insight_chat,
|
||||
turn_registry,
|
||||
face_client,
|
||||
clip_client,
|
||||
insight_job_dao,
|
||||
insight_job_handles,
|
||||
precomputed_reel_dao,
|
||||
user_ai_prefs_dao,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,14 +160,25 @@ impl AppState {
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
// Initialize AI clients
|
||||
let ollama = build_ollama_from_env();
|
||||
let ollama_primary_url = env::var("OLLAMA_PRIMARY_URL").unwrap_or_else(|_| {
|
||||
env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string())
|
||||
});
|
||||
let ollama_fallback_url = env::var("OLLAMA_FALLBACK_URL").ok();
|
||||
let ollama_primary_model = env::var("OLLAMA_PRIMARY_MODEL")
|
||||
.or_else(|_| env::var("OLLAMA_MODEL"))
|
||||
.unwrap_or_else(|_| "nemotron-3-nano:30b".to_string());
|
||||
let ollama_fallback_model = env::var("OLLAMA_FALLBACK_MODEL").ok();
|
||||
|
||||
let ollama = OllamaClient::new(
|
||||
ollama_primary_url,
|
||||
ollama_fallback_url,
|
||||
ollama_primary_model,
|
||||
ollama_fallback_model,
|
||||
);
|
||||
|
||||
let openrouter = build_openrouter_from_env();
|
||||
let openrouter_allowed_models = parse_openrouter_allowed_models();
|
||||
|
||||
let llamacpp = build_llamacpp_from_env();
|
||||
let llamacpp_allowed_models = parse_llamacpp_allowed_models();
|
||||
|
||||
let sms_api_url =
|
||||
env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
|
||||
let sms_api_token = env::var("SMS_API_TOKEN").ok();
|
||||
@@ -245,9 +198,6 @@ impl Default for AppState {
|
||||
.or_else(|| env::var("APOLLO_API_BASE_URL").ok());
|
||||
let face_client = FaceClient::new(face_client_url);
|
||||
|
||||
// CLIP inference client. Same env var fallback as face_client.
|
||||
let clip_client = ClipClient::from_env();
|
||||
|
||||
// Initialize DAOs
|
||||
let insight_dao: Arc<Mutex<Box<dyn InsightDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteInsightDao::new())));
|
||||
@@ -275,20 +225,6 @@ impl Default for AppState {
|
||||
let face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new())));
|
||||
|
||||
// Initialize insight generation job DAO (async generation tracking)
|
||||
let insight_job_dao: Arc<Mutex<Box<dyn InsightGenerationJobDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteInsightGenerationJobDao::new())));
|
||||
let insight_job_handles: Arc<Mutex<HashMap<i32, tokio::task::AbortHandle>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// Initialize precomputed reel DAO (nightly pre-generation ledger)
|
||||
let precomputed_reel_dao: Arc<Mutex<Box<dyn PrecomputedReelDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqlitePrecomputedReelDao::new())));
|
||||
|
||||
// Initialize user AI preferences DAO (Section E)
|
||||
let user_ai_prefs_dao: Arc<Mutex<Box<dyn UserAiPrefsDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new())));
|
||||
|
||||
// Load base path and ensure the primary library row reflects it.
|
||||
let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env");
|
||||
let mut seed_conn = connect();
|
||||
@@ -304,7 +240,6 @@ impl Default for AppState {
|
||||
let insight_generator = InsightGenerator::new(
|
||||
ollama.clone(),
|
||||
openrouter.clone(),
|
||||
llamacpp.clone(),
|
||||
sms_client.clone(),
|
||||
apollo_client.clone(),
|
||||
insight_dao.clone(),
|
||||
@@ -326,18 +261,12 @@ impl Default for AppState {
|
||||
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
let insight_chat = Arc::new(InsightChatService::new(
|
||||
Arc::new(insight_generator.clone()),
|
||||
ollama.clone(),
|
||||
openrouter.clone(),
|
||||
insight_dao.clone(),
|
||||
chat_locks,
|
||||
));
|
||||
|
||||
// Turn registry for reconnectable chat turns. 5-minute timeout for
|
||||
// stale turns (background cleaner drops entries older than this).
|
||||
let timeout_secs: u64 = env::var("INSIGHT_CHAT_TURN_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(300);
|
||||
let turn_registry = Arc::new(TurnRegistry::new(timeout_secs));
|
||||
|
||||
// Ensure preview clips directory exists
|
||||
let preview_clips_path =
|
||||
env::var("PREVIEW_CLIPS_DIRECTORY").unwrap_or_else(|_| "preview_clips".to_string());
|
||||
@@ -355,19 +284,11 @@ impl Default for AppState {
|
||||
ollama,
|
||||
openrouter,
|
||||
openrouter_allowed_models,
|
||||
llamacpp,
|
||||
llamacpp_allowed_models,
|
||||
sms_client,
|
||||
insight_generator,
|
||||
insight_chat,
|
||||
turn_registry,
|
||||
preview_dao,
|
||||
face_client,
|
||||
clip_client,
|
||||
insight_job_dao,
|
||||
insight_job_handles,
|
||||
precomputed_reel_dao,
|
||||
user_ai_prefs_dao,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -403,61 +324,10 @@ fn parse_openrouter_allowed_models() -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the `OllamaClient` from environment variables — the canonical
|
||||
/// `OLLAMA_*` wiring shared by the server (`AppState::default`) and the
|
||||
/// standalone binaries (which predate this helper and used to copy it).
|
||||
pub fn build_ollama_from_env() -> OllamaClient {
|
||||
let primary_url = env::var("OLLAMA_PRIMARY_URL").unwrap_or_else(|_| {
|
||||
env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string())
|
||||
});
|
||||
let fallback_url = env::var("OLLAMA_FALLBACK_URL").ok();
|
||||
let primary_model = env::var("OLLAMA_PRIMARY_MODEL")
|
||||
.or_else(|_| env::var("OLLAMA_MODEL"))
|
||||
.unwrap_or_else(|_| "nemotron-3-nano:30b".to_string());
|
||||
let fallback_model = env::var("OLLAMA_FALLBACK_MODEL").ok();
|
||||
|
||||
OllamaClient::new(primary_url, fallback_url, primary_model, fallback_model)
|
||||
}
|
||||
|
||||
/// Build a `LlamaCppClient` from environment variables. Returns `None` when
|
||||
/// `LLAMA_SWAP_URL` is unset. The client is constructed unconditionally
|
||||
/// when the URL is set (so it's available even under `LLM_BACKEND=ollama`
|
||||
/// for ad-hoc tooling), but the agentic / chat paths only route through it
|
||||
/// when `LLM_BACKEND=llamacpp`. Slot ids default to the names the bundled
|
||||
/// `llama-swap/config.yaml` uses — `chat` / `vision` / `embed`.
|
||||
pub fn build_llamacpp_from_env() -> Option<Arc<LlamaCppClient>> {
|
||||
let base_url = env::var("LLAMA_SWAP_URL").ok()?;
|
||||
let primary_model = env::var("LLAMA_SWAP_PRIMARY_MODEL").ok();
|
||||
let mut client = LlamaCppClient::new(Some(base_url), primary_model);
|
||||
if let Ok(model) = env::var("LLAMA_SWAP_EMBEDDING_MODEL") {
|
||||
client.set_embedding_model(model);
|
||||
}
|
||||
if let Ok(model) = env::var("LLAMA_SWAP_VISION_MODEL") {
|
||||
client.set_vision_model(model);
|
||||
}
|
||||
if let Ok(model) = env::var("LLAMA_SWAP_TTS_MODEL") {
|
||||
client.set_tts_model(model);
|
||||
}
|
||||
Some(Arc::new(client))
|
||||
}
|
||||
|
||||
/// Parse `LLAMA_SWAP_ALLOWED_MODELS` (comma-separated) into a vec. Used to
|
||||
/// populate the model picker when `LLM_BACKEND=llamacpp` — `/insights/models`
|
||||
/// surfaces these slots with capabilities. Empty when unset.
|
||||
fn parse_llamacpp_allowed_models() -> Vec<String> {
|
||||
env::var("LLAMA_SWAP_ALLOWED_MODELS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl AppState {
|
||||
/// Creates an AppState instance for testing with temporary directories
|
||||
pub fn test_state() -> Self {
|
||||
use crate::database::insight_generation_job_dao::SqliteInsightGenerationJobDao;
|
||||
use actix::Actor;
|
||||
// Create a base temporary directory
|
||||
let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
|
||||
@@ -516,7 +386,6 @@ impl AppState {
|
||||
let insight_generator = InsightGenerator::new(
|
||||
ollama.clone(),
|
||||
None,
|
||||
None,
|
||||
sms_client.clone(),
|
||||
apollo_client.clone(),
|
||||
insight_dao.clone(),
|
||||
@@ -536,13 +405,12 @@ impl AppState {
|
||||
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
let insight_chat = Arc::new(InsightChatService::new(
|
||||
Arc::new(insight_generator.clone()),
|
||||
ollama.clone(),
|
||||
None,
|
||||
insight_dao.clone(),
|
||||
chat_locks,
|
||||
));
|
||||
|
||||
// Turn registry for test state.
|
||||
let turn_registry = Arc::new(TurnRegistry::new(300));
|
||||
|
||||
// Initialize test preview DAO
|
||||
let preview_dao: Arc<Mutex<Box<dyn PreviewDao>>> =
|
||||
Arc::new(Mutex::new(Box::new(SqlitePreviewDao::new())));
|
||||
@@ -566,19 +434,11 @@ impl AppState {
|
||||
ollama,
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
Vec::new(),
|
||||
sms_client,
|
||||
insight_generator,
|
||||
insight_chat,
|
||||
turn_registry,
|
||||
preview_dao,
|
||||
FaceClient::new(None), // disabled in test
|
||||
ClipClient::new(None), // disabled in test
|
||||
Arc::new(Mutex::new(Box::new(SqliteInsightGenerationJobDao::new()))), // placeholder for test
|
||||
Arc::new(Mutex::new(HashMap::new())), // placeholder for test
|
||||
Arc::new(Mutex::new(Box::new(SqlitePrecomputedReelDao::new()))), // placeholder for test
|
||||
Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new()))), // placeholder for test
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ async fn get_tags<D: TagDao>(
|
||||
// this file, so tags added under one library show up under the
|
||||
// others when they hold the same file. Falls back to direct rel_path
|
||||
// match when the file hasn't been hashed yet.
|
||||
let library = libraries::resolve_library_param_state(&app_state, request.library.as_deref())
|
||||
let library = libraries::resolve_library_param(&app_state, request.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
|
||||
@@ -144,7 +144,6 @@ impl PreviewDao for TestPreviewDao {
|
||||
} else {
|
||||
Err(DbError {
|
||||
kind: DbErrorKind::UpdateError,
|
||||
source: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,7 @@
|
||||
//! skip them silently.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use image::GenericImageView;
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use lazy_static::lazy_static;
|
||||
use log::{debug, error, info, warn};
|
||||
use opentelemetry::{
|
||||
@@ -29,26 +26,6 @@ use crate::libraries;
|
||||
use crate::otel::global_tracer;
|
||||
use crate::video::actors::{generate_image_thumbnail_ffmpeg, generate_video_thumbnail};
|
||||
|
||||
/// Maximum long-edge size (px) for the large preview tier. Tuned to look
|
||||
/// crisp full-screen on a 3× phone (≈1290×2796 native) and to hold up
|
||||
/// through a few stops of pinch-zoom before the original streams in.
|
||||
/// Bigger doesn't help: callers that need true full resolution request
|
||||
/// `size=full` and the handler streams the original bytes.
|
||||
pub const LARGE_PREVIEW_MAX_DIM: u32 = 2048;
|
||||
|
||||
/// JPEG quality for the large and xlarge preview tiers. 85 is the
|
||||
/// conventional "indistinguishable from source at viewing size" point —
|
||||
/// well above the `image` crate's default ~75, but well below quality-90+
|
||||
/// territory where file size doubles for no perceptible win.
|
||||
const LARGE_PREVIEW_JPEG_QUALITY: u8 = 85;
|
||||
|
||||
/// Maximum long-edge size (px) for the xlarge preview tier. Bridges the
|
||||
/// gap between `large` (2048px, ~16MB decoded) and the original bytes
|
||||
/// (potentially 48+ MP / ~192MB decoded). At 4096px the decoded bitmap is
|
||||
/// ~64MB — enough for 2-3× pinch-zoom on any phone before the viewer
|
||||
/// needs to stream the true original.
|
||||
pub const XLARGE_PREVIEW_MAX_DIM: u32 = 4096;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref IMAGE_GAUGE: IntGauge = IntGauge::new(
|
||||
"imageserver_image_total",
|
||||
@@ -112,186 +89,6 @@ pub fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate the on-demand large-preview tier (≈2048 long edge JPEG).
|
||||
///
|
||||
/// Mirrors [`generate_image_thumbnail`]'s decode waterfall — embedded RAW
|
||||
/// preview, then ffmpeg for HEIC/HEIF, then the `image` crate — but
|
||||
/// resizes to [`LARGE_PREVIEW_MAX_DIM`] instead of 200 and encodes at
|
||||
/// quality 85 rather than the crate default. Caller is expected to have
|
||||
/// already created the destination's parent dir.
|
||||
///
|
||||
/// Does not upscale: if the source's long edge is already below the cap,
|
||||
/// the file is encoded at its native size (still re-saved as JPEG so the
|
||||
/// served bytes match for callers that key off `Content-Length`).
|
||||
pub fn generate_large_preview(src: &Path, dest: &Path) -> std::io::Result<()> {
|
||||
let orientation = exif::read_orientation(src).unwrap_or(1);
|
||||
|
||||
// RAW: prefer the in-file embedded JPEG preview over raw-sensor decode.
|
||||
// The preview is typically already 1–2 MP and avoids RAW codec quirks.
|
||||
if let Some(preview) = exif::extract_embedded_jpeg_preview(src) {
|
||||
let img = image::load_from_memory(&preview).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("decode embedded preview {:?}: {}", src, e),
|
||||
)
|
||||
})?;
|
||||
let img = exif::apply_orientation(img, orientation);
|
||||
return encode_large_jpeg(img, dest);
|
||||
}
|
||||
|
||||
if file_types::needs_ffmpeg_thumbnail(src) {
|
||||
return generate_large_preview_ffmpeg(src, dest);
|
||||
}
|
||||
|
||||
let img = image::open(src).map_err(|e| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}: {}", src, e))
|
||||
})?;
|
||||
let img = exif::apply_orientation(img, orientation);
|
||||
encode_large_jpeg(img, dest)
|
||||
}
|
||||
|
||||
/// Resize-if-needed + JPEG-encode at q85. Used by both the embedded-preview
|
||||
/// and image-crate-decode branches of `generate_large_preview`.
|
||||
fn encode_large_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<()> {
|
||||
let (w, h) = img.dimensions();
|
||||
let max_dim = w.max(h);
|
||||
// Avoid upscaling tiny sources — pointless work and adds nothing for
|
||||
// the viewer. `thumbnail` would scale up freely; explicit guard.
|
||||
let scaled = if max_dim > LARGE_PREVIEW_MAX_DIM {
|
||||
img.thumbnail(LARGE_PREVIEW_MAX_DIM, LARGE_PREVIEW_MAX_DIM)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let file = std::fs::File::create(dest)
|
||||
.map_err(|e| std::io::Error::other(format!("create {:?}: {}", dest, e)))?;
|
||||
let mut writer = std::io::BufWriter::new(file);
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut writer, LARGE_PREVIEW_JPEG_QUALITY);
|
||||
encoder
|
||||
.encode_image(&scaled)
|
||||
.map_err(|e| std::io::Error::other(format!("encode {:?}: {}", dest, e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors
|
||||
/// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales
|
||||
/// to the large-preview cap instead of 200.
|
||||
fn generate_large_preview_ffmpeg(src: &Path, dest: &Path) -> std::io::Result<()> {
|
||||
// scale=W:-1 with force_original_aspect_ratio=decrease + the min(iw,W)
|
||||
// trick caps the long edge regardless of orientation, mirroring what
|
||||
// image::thumbnail does for the non-ffmpeg branch.
|
||||
let vf = format!(
|
||||
"scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'",
|
||||
cap = LARGE_PREVIEW_MAX_DIM
|
||||
);
|
||||
let output = Command::new("ffmpeg")
|
||||
.arg("-y")
|
||||
.arg("-i")
|
||||
.arg(src)
|
||||
.arg("-vframes")
|
||||
.arg("1")
|
||||
.arg("-vf")
|
||||
.arg(&vf)
|
||||
.arg("-q:v")
|
||||
// ffmpeg's mjpeg qscale: 2 ≈ ~q95, 5 ≈ ~q85, 10 ≈ ~q70. We pick
|
||||
// 5 to match the non-ffmpeg branch's q85 target.
|
||||
.arg("5")
|
||||
.arg("-f")
|
||||
.arg("image2")
|
||||
.arg("-c:v")
|
||||
.arg("mjpeg")
|
||||
.arg(dest)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"ffmpeg failed ({}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate the on-demand xlarge-preview tier (≈4096 long edge JPEG).
|
||||
///
|
||||
/// Same waterfall as [`generate_large_preview`] but targeting
|
||||
/// [`XLARGE_PREVIEW_MAX_DIM`]. Sources whose long edge is already below
|
||||
/// the cap are encoded at native size (no upscale).
|
||||
pub fn generate_xlarge_preview(src: &Path, dest: &Path) -> std::io::Result<()> {
|
||||
let orientation = exif::read_orientation(src).unwrap_or(1);
|
||||
|
||||
if let Some(preview) = exif::extract_embedded_jpeg_preview(src) {
|
||||
let img = image::load_from_memory(&preview).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("decode embedded preview {:?}: {}", src, e),
|
||||
)
|
||||
})?;
|
||||
let img = exif::apply_orientation(img, orientation);
|
||||
return encode_xlarge_jpeg(img, dest);
|
||||
}
|
||||
|
||||
if file_types::needs_ffmpeg_thumbnail(src) {
|
||||
return generate_xlarge_preview_ffmpeg(src, dest);
|
||||
}
|
||||
|
||||
let img = image::open(src).map_err(|e| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}: {}", src, e))
|
||||
})?;
|
||||
let img = exif::apply_orientation(img, orientation);
|
||||
encode_xlarge_jpeg(img, dest)
|
||||
}
|
||||
|
||||
fn encode_xlarge_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<()> {
|
||||
let (w, h) = img.dimensions();
|
||||
let max_dim = w.max(h);
|
||||
let scaled = if max_dim > XLARGE_PREVIEW_MAX_DIM {
|
||||
img.thumbnail(XLARGE_PREVIEW_MAX_DIM, XLARGE_PREVIEW_MAX_DIM)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let file = std::fs::File::create(dest)
|
||||
.map_err(|e| std::io::Error::other(format!("create {:?}: {}", dest, e)))?;
|
||||
let mut writer = std::io::BufWriter::new(file);
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut writer, LARGE_PREVIEW_JPEG_QUALITY);
|
||||
encoder
|
||||
.encode_image(&scaled)
|
||||
.map_err(|e| std::io::Error::other(format!("encode {:?}: {}", dest, e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_xlarge_preview_ffmpeg(src: &Path, dest: &Path) -> std::io::Result<()> {
|
||||
let vf = format!(
|
||||
"scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'",
|
||||
cap = XLARGE_PREVIEW_MAX_DIM
|
||||
);
|
||||
let output = Command::new("ffmpeg")
|
||||
.arg("-y")
|
||||
.arg("-i")
|
||||
.arg(src)
|
||||
.arg("-vframes")
|
||||
.arg("1")
|
||||
.arg("-vf")
|
||||
.arg(&vf)
|
||||
.arg("-q:v")
|
||||
.arg("5")
|
||||
.arg("-f")
|
||||
.arg("image2")
|
||||
.arg("-c:v")
|
||||
.arg("mjpeg")
|
||||
.arg(dest)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"ffmpeg failed ({}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String]) {
|
||||
let tracer = global_tracer();
|
||||
let span = tracer.start("creating thumbnails");
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
//! `/photos/search/unified?q=<natural language>` — unified NL photo search.
|
||||
//!
|
||||
//! One free-text box that composes the two existing engines instead of making
|
||||
//! the user pick between them:
|
||||
//! 1. A grounded local-LLM call ([`crate::ai::nl_query`]) translates the
|
||||
//! query into a structured filter + a semantic term.
|
||||
//! 2. Structured filters (tags / EXIF / geo / date / media-type) define the
|
||||
//! candidate set; the semantic term ranks within it via CLIP.
|
||||
//!
|
||||
//! Path A (orchestration): we reuse `clip_search`'s scoring core and the
|
||||
//! existing `ExifDao` / `TagDao` queries, joining on `content_hash`. EXIF rows
|
||||
//! are the universal candidate carrier — each has `(library_id, file_path,
|
||||
//! content_hash, date_taken)` — so the structured filter is just a predicate
|
||||
//! over them, and the CLIP hits (which key on `content_hash`) intersect by
|
||||
//! hash. No new schema, no surgery on `list_photos`.
|
||||
//!
|
||||
//! Degenerate cases collapse to the existing behavior: semantic-only → plain
|
||||
//! CLIP search; filters-only → a date-sorted filtered listing.
|
||||
//!
|
||||
//! Person filtering is intentionally deferred (no person→photos resolver yet).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::ai::backend::{BackendKind, SamplingOverrides};
|
||||
use crate::ai::nl_query::{StructuredQuery, translate_nl_query};
|
||||
use crate::clip_search::{
|
||||
SearchHit, parse_library_scope, resolve_hits, score_error_response, score_photos,
|
||||
};
|
||||
use crate::data::Claims;
|
||||
use crate::database::ExifDao;
|
||||
use crate::file_types::{is_image_file, is_video_file};
|
||||
use crate::geo::{forward_geocode, gps_bounding_box, haversine_distance};
|
||||
use crate::tags::TagDao;
|
||||
use actix_web::HttpResponse;
|
||||
use actix_web::web::{Data, Query};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UnifiedQuery {
|
||||
/// Natural-language query. Required; empty triggers 400.
|
||||
pub q: String,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: usize,
|
||||
#[serde(default)]
|
||||
pub offset: usize,
|
||||
/// CLIP cosine floor for the semantic ranking stage. Same default as the
|
||||
/// plain search endpoint.
|
||||
#[serde(default = "default_threshold")]
|
||||
pub threshold: f32,
|
||||
/// Legacy single-library scope (see clip_search).
|
||||
pub library: Option<i32>,
|
||||
/// Multi-library scope, comma-separated ids.
|
||||
pub library_ids: Option<String>,
|
||||
/// Optional model override. The client passes the user's currently-selected
|
||||
/// local model so the translation step reuses a model that's already loaded
|
||||
/// (avoids a llama-swap eviction / cold start). Falls back to the configured
|
||||
/// default local model when absent. Local only — no hybrid here.
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
fn default_limit() -> usize {
|
||||
20
|
||||
}
|
||||
fn default_threshold() -> f32 {
|
||||
0.20
|
||||
}
|
||||
|
||||
/// A geocoded place echoed back so the client can show / edit the location
|
||||
/// filter it actually searched.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ResolvedPlace {
|
||||
display_name: String,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
radius_km: f64,
|
||||
}
|
||||
|
||||
/// How the server interpreted the NL query — echoed to the client to render
|
||||
/// editable filter chips. tag ids map to the client's existing tag list.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Interpreted {
|
||||
semantic: Option<String>,
|
||||
tag_ids: Vec<i32>,
|
||||
exclude_tag_ids: Vec<i32>,
|
||||
/// Words the model treated as tags that don't exist in the vocab; folded
|
||||
/// into the semantic term and surfaced here so the UI can explain it.
|
||||
unmatched_tags: Vec<String>,
|
||||
camera_make: Option<String>,
|
||||
camera_model: Option<String>,
|
||||
lens_model: Option<String>,
|
||||
date_from: Option<i64>,
|
||||
date_to: Option<i64>,
|
||||
media_type: Option<String>,
|
||||
place: Option<ResolvedPlace>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UnifiedResponse {
|
||||
query: String,
|
||||
interpreted: Interpreted,
|
||||
/// CLIP model version used for ranking; `None` when the query had no
|
||||
/// semantic term (filters-only).
|
||||
model_version: Option<String>,
|
||||
/// Embeddings scored by CLIP (0 when filters-only).
|
||||
considered: usize,
|
||||
/// Matches before pagination.
|
||||
total_matching: usize,
|
||||
offset: usize,
|
||||
results: Vec<SearchHit>,
|
||||
}
|
||||
|
||||
#[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
|
||||
/// rather than being dropped.
|
||||
fn effective_semantic(sq: &StructuredQuery) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(s) = sq.semantic.as_deref() {
|
||||
parts.push(s.to_string());
|
||||
}
|
||||
parts.extend(sq.unmatched_tags.iter().cloned());
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unified_search<TagD: TagDao>(
|
||||
_: Claims,
|
||||
state: Data<AppState>,
|
||||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||||
tag_dao: Data<Mutex<TagD>>,
|
||||
query: Query<UnifiedQuery>,
|
||||
) -> HttpResponse {
|
||||
let nl = query.q.trim().to_string();
|
||||
if nl.is_empty() {
|
||||
return bad_request("query parameter `q` is required");
|
||||
}
|
||||
|
||||
let limit = query.limit.clamp(1, 200);
|
||||
let offset = query.offset;
|
||||
let threshold = query.threshold.clamp(-1.0, 1.0);
|
||||
|
||||
let library_ids = match parse_library_scope(query.library_ids.as_deref(), query.library) {
|
||||
Ok(ids) => ids,
|
||||
Err(msg) => return bad_request(msg),
|
||||
};
|
||||
|
||||
let ctx = opentelemetry::Context::current();
|
||||
|
||||
// ── 1. Translate the NL query, grounded on the real tag vocabulary ──
|
||||
let tag_vocab: Vec<(i32, String)> = {
|
||||
let mut dao = tag_dao.lock().expect("tag dao");
|
||||
match dao.get_all_tags(&ctx, None) {
|
||||
Ok(tags) => tags.into_iter().map(|(_, t)| (t.id, t.name)).collect(),
|
||||
Err(e) => {
|
||||
log::warn!("unified_search: get_all_tags failed: {e:?}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Respect env/config for the LLM backend (LLM_BACKEND → ollama or
|
||||
// llama-swap); local only, no hybrid, per the feature's design.
|
||||
//
|
||||
// Translation-model precedence:
|
||||
// 1. UNIFIED_SEARCH_MODEL env — pin a small, fast model that can stay
|
||||
// co-resident with CLIP (and the chat model) so translation never
|
||||
// evicts them. This is the recommended setup on a tight VRAM budget.
|
||||
// 2. the client-selected model — routes translation to whatever the user
|
||||
// already has loaded (no swap) when no dedicated model is pinned.
|
||||
// 3. None → resolve_backend uses the configured default local model.
|
||||
let translation_model = std::env::var("UNIFIED_SEARCH_MODEL")
|
||||
.ok()
|
||||
.filter(|m| !m.trim().is_empty())
|
||||
.or_else(|| query.model.clone())
|
||||
.filter(|m| !m.trim().is_empty());
|
||||
let overrides = SamplingOverrides {
|
||||
model: translation_model,
|
||||
num_ctx: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
top_k: None,
|
||||
min_p: None,
|
||||
enable_thinking: None,
|
||||
};
|
||||
let backend = match state
|
||||
.insight_generator
|
||||
.resolve_backend(BackendKind::Local, &overrides)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("unified_search: resolve_backend failed: {e:?}");
|
||||
return HttpResponse::ServiceUnavailable().json(ErrorBody {
|
||||
error: "LLM backend unavailable".into(),
|
||||
});
|
||||
}
|
||||
};
|
||||
log::info!("unified_search: translating with model={}", backend.model());
|
||||
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let sq = match translate_nl_query(backend.chat(), &nl, &tag_vocab, today).await {
|
||||
Ok(sq) => sq,
|
||||
Err(e) => {
|
||||
log::warn!("unified_search: translate_nl_query failed: {e:?}");
|
||||
return HttpResponse::BadGateway().json(ErrorBody {
|
||||
error: "could not interpret the query".into(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ── 2. Forward-geocode the place name into a gps circle ──
|
||||
let resolved_place = match sq.place.as_deref() {
|
||||
Some(p) => forward_geocode(p).await.map(|g| ResolvedPlace {
|
||||
display_name: g.display_name,
|
||||
lat: g.lat,
|
||||
lon: g.lon,
|
||||
radius_km: g.radius_km,
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
let gps = resolved_place.as_ref().map(|p| (p.lat, p.lon, p.radius_km));
|
||||
|
||||
let semantic = effective_semantic(&sq);
|
||||
|
||||
let has_exif_filter = sq.camera_make.is_some()
|
||||
|| sq.camera_model.is_some()
|
||||
|| sq.lens_model.is_some()
|
||||
|| sq.date_from.is_some()
|
||||
|| sq.date_to.is_some();
|
||||
let has_struct =
|
||||
has_exif_filter || gps.is_some() || !sq.tag_ids.is_empty() || sq.media_type.is_some();
|
||||
|
||||
// Stage trace: what the model extracted + whether a structured filter is
|
||||
// active. The chips show this to the user too, but logging it makes the
|
||||
// "why no results" path debuggable from the server side.
|
||||
log::info!(
|
||||
"unified_search: q={nl:?} semantic={:?} tag_ids={:?} exclude={:?} place={:?} gps={:?} date=({:?},{:?}) media={:?} unmatched={:?} has_struct={has_struct}",
|
||||
sq.semantic,
|
||||
sq.tag_ids,
|
||||
sq.exclude_tag_ids,
|
||||
resolved_place.as_ref().map(|p| p.display_name.as_str()),
|
||||
gps,
|
||||
sq.date_from,
|
||||
sq.date_to,
|
||||
sq.media_type,
|
||||
sq.unmatched_tags,
|
||||
);
|
||||
|
||||
// ── 3. Build the structured candidate set (EXIF rows passing every
|
||||
// filter). Skipped entirely for a pure-semantic query. ──
|
||||
let mut candidate: Vec<crate::database::models::ImageExif> = Vec::new();
|
||||
let mut allowed_hashes: HashSet<String> = HashSet::new();
|
||||
if has_struct {
|
||||
// Tag membership set (rel_path only — same cross-library imprecision
|
||||
// as the existing /photos tag listing). ANY-mode: a photo matches if
|
||||
// it carries any of the named tags. ALL-mode over-constrains NL
|
||||
// queries (the model maps several words to tags and few photos carry
|
||||
// them all); the semantic term does the precision work instead.
|
||||
let tag_set: Option<HashSet<String>> = if sq.tag_ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let mut dao = tag_dao.lock().expect("tag dao");
|
||||
match dao.get_files_with_any_tag_ids(
|
||||
sq.tag_ids.clone(),
|
||||
sq.exclude_tag_ids.clone(),
|
||||
&ctx,
|
||||
) {
|
||||
Ok(files) => Some(files.into_iter().map(|f| f.file_name).collect()),
|
||||
Err(e) => {
|
||||
log::warn!("unified_search: tag filter failed: {e:?}");
|
||||
Some(HashSet::new())
|
||||
}
|
||||
}
|
||||
};
|
||||
log::info!(
|
||||
"unified_search: tag_ids={:?} -> tag_set_files={:?}",
|
||||
sq.tag_ids,
|
||||
tag_set.as_ref().map(|s| s.len())
|
||||
);
|
||||
|
||||
// EXIF query handles camera/lens/gps-box/date. With no EXIF filters
|
||||
// it returns the whole table, which we then narrow by the predicates
|
||||
// below (tags / media / scope). Fine at personal-library scale.
|
||||
let gps_bounds = gps.map(|(lat, lon, r)| gps_bounding_box(lat, lon, r));
|
||||
let rows = {
|
||||
let mut dao = exif_dao.lock().expect("exif dao");
|
||||
dao.query_by_exif(
|
||||
&ctx,
|
||||
None, // scope filtered in-Rust to support multi-library
|
||||
sq.camera_make.as_deref(),
|
||||
sq.camera_model.as_deref(),
|
||||
sq.lens_model.as_deref(),
|
||||
gps_bounds,
|
||||
sq.date_from,
|
||||
sq.date_to,
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("unified_search: query_by_exif failed: {e:?}");
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
|
||||
candidate = rows
|
||||
.into_iter()
|
||||
.filter(|row| {
|
||||
// Library scope.
|
||||
if !library_ids.is_empty() && !library_ids.contains(&row.library_id) {
|
||||
return false;
|
||||
}
|
||||
// Precise GPS distance (the EXIF query only did a coarse box).
|
||||
if let Some((lat, lon, radius_km)) = gps {
|
||||
match (row.gps_latitude, row.gps_longitude) {
|
||||
(Some(plat), Some(plon)) => {
|
||||
if haversine_distance(lat, lon, plat as f64, plon as f64) > radius_km {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
// Media type.
|
||||
if let Some(mt) = sq.media_type.as_deref() {
|
||||
let p = Path::new(&row.file_path);
|
||||
let ok = if mt == "video" {
|
||||
is_video_file(p)
|
||||
} else {
|
||||
is_image_file(p)
|
||||
};
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Tag membership.
|
||||
if let Some(ts) = &tag_set
|
||||
&& !ts.contains(&row.file_path)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
allowed_hashes = candidate
|
||||
.iter()
|
||||
.filter_map(|r| r.content_hash.clone())
|
||||
.collect();
|
||||
log::info!(
|
||||
"unified_search: candidate_rows={} allowed_hashes={}",
|
||||
candidate.len(),
|
||||
allowed_hashes.len()
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Rank ──
|
||||
match semantic {
|
||||
Some(ref sem) => {
|
||||
// When structured filters are present they ARE the constraint —
|
||||
// CLIP only ranks within the candidate set. So drop the global
|
||||
// similarity threshold (it's tuned for whole-library search and
|
||||
// would pre-discard filter-matching photos that scored just under
|
||||
// it — e.g. a 2022 beach photo at 0.18 — before the intersection
|
||||
// ever runs). With no filters, keep the user's threshold for the
|
||||
// plain semantic case.
|
||||
let clip_threshold = if has_struct { -1.0 } else { threshold };
|
||||
let scored = match score_photos(
|
||||
&state,
|
||||
&exif_dao,
|
||||
sem,
|
||||
&library_ids,
|
||||
clip_threshold,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(e) => return score_error_response(e),
|
||||
};
|
||||
let considered = scored.considered;
|
||||
let clip_hits = scored.hits.len();
|
||||
let hits: Vec<(f32, String)> = if has_struct {
|
||||
scored
|
||||
.hits
|
||||
.into_iter()
|
||||
.filter(|(_, h)| allowed_hashes.contains(h))
|
||||
.collect()
|
||||
} else {
|
||||
scored.hits
|
||||
};
|
||||
log::info!(
|
||||
"unified_search: clip considered={considered} hits={clip_hits} after_struct_filter={}",
|
||||
hits.len()
|
||||
);
|
||||
let total_matching = hits.len();
|
||||
let page = paginate(&hits, offset, limit);
|
||||
let results = resolve_hits(&exif_dao, &page);
|
||||
HttpResponse::Ok().json(UnifiedResponse {
|
||||
query: nl,
|
||||
interpreted: interpreted(&sq, resolved_place),
|
||||
model_version: Some(scored.model_version),
|
||||
considered: scored.considered,
|
||||
total_matching,
|
||||
offset,
|
||||
results,
|
||||
})
|
||||
}
|
||||
None => {
|
||||
// Filters-only: no semantic term. Require at least one filter,
|
||||
// then return the candidate set newest-first.
|
||||
if !has_struct {
|
||||
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();
|
||||
log::info!("unified_search: filters-only matches={total_matching}");
|
||||
let end = (offset + limit).min(total_matching);
|
||||
let results: Vec<SearchHit> = if offset >= total_matching {
|
||||
Vec::new()
|
||||
} else {
|
||||
candidate[offset..end]
|
||||
.iter()
|
||||
.map(|r| SearchHit {
|
||||
library_id: r.library_id,
|
||||
rel_path: r.file_path.clone(),
|
||||
content_hash: r.content_hash.clone().unwrap_or_default(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
HttpResponse::Ok().json(UnifiedResponse {
|
||||
query: nl,
|
||||
interpreted: interpreted(&sq, resolved_place),
|
||||
model_version: None,
|
||||
considered: 0,
|
||||
total_matching,
|
||||
offset,
|
||||
results,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Slice a sorted hit list at `[offset, offset+limit)`, tolerating
|
||||
/// out-of-range offsets (empty page).
|
||||
fn paginate(hits: &[(f32, String)], offset: usize, limit: usize) -> Vec<(f32, String)> {
|
||||
if offset >= hits.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let end = (offset + limit).min(hits.len());
|
||||
hits[offset..end].to_vec()
|
||||
}
|
||||
|
||||
fn interpreted(sq: &StructuredQuery, place: Option<ResolvedPlace>) -> Interpreted {
|
||||
Interpreted {
|
||||
semantic: sq.semantic.clone(),
|
||||
tag_ids: sq.tag_ids.clone(),
|
||||
exclude_tag_ids: sq.exclude_tag_ids.clone(),
|
||||
unmatched_tags: sq.unmatched_tags.clone(),
|
||||
camera_make: sq.camera_make.clone(),
|
||||
camera_model: sq.camera_model.clone(),
|
||||
lens_model: sq.lens_model.clone(),
|
||||
date_from: sq.date_from,
|
||||
date_to: sq.date_to,
|
||||
media_type: sq.media_type.clone(),
|
||||
place,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai::nl_query::StructuredQuery;
|
||||
|
||||
#[test]
|
||||
fn effective_semantic_combines_semantic_and_unmatched() {
|
||||
let sq = StructuredQuery {
|
||||
semantic: Some("sunset".into()),
|
||||
unmatched_tags: vec!["golden hour".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
effective_semantic(&sq).as_deref(),
|
||||
Some("sunset golden hour")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_semantic_none_when_empty() {
|
||||
let sq = StructuredQuery::default();
|
||||
assert_eq!(effective_semantic(&sq), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_semantic_unmatched_only() {
|
||||
let sq = StructuredQuery {
|
||||
unmatched_tags: vec!["disco".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(effective_semantic(&sq).as_deref(), Some("disco"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_handles_out_of_range_offset() {
|
||||
let hits = vec![(0.9, "a".to_string()), (0.8, "b".to_string())];
|
||||
assert_eq!(paginate(&hits, 5, 10).len(), 0);
|
||||
assert_eq!(paginate(&hits, 0, 1).len(), 1);
|
||||
assert_eq!(paginate(&hits, 1, 10).len(), 1);
|
||||
}
|
||||
}
|
||||
+241
-211
@@ -1,18 +1,18 @@
|
||||
use crate::content_hash;
|
||||
use crate::database::PreviewDao;
|
||||
use crate::libraries::Library;
|
||||
use crate::otel::global_tracer;
|
||||
use crate::thumbnails::is_video;
|
||||
use crate::video::ffmpeg::{generate_preview_clip, get_duration_seconds_blocking};
|
||||
use crate::video::hls_paths;
|
||||
use actix::prelude::*;
|
||||
use log::{debug, error, info, warn};
|
||||
use log::{debug, error, info, trace, warn};
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry::trace::{Span, Status, Tracer};
|
||||
use std::io::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Semaphore;
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
// ffmpeg -i test.mp4 -c:v h264 -flags +cgop -g 30 -hls_time 3 out.m3u8
|
||||
// ffmpeg -i "filename.mp4" -preset veryfast -c:v libx264 -f hls -hls_list_size 100 -hls_time 2 -crf 24 -vf scale=1080:-2,setsar=1:1 attempt/vid_out.m3u8
|
||||
|
||||
@@ -22,14 +22,89 @@ impl Actor for StreamActor {
|
||||
type Context = Context<Self>;
|
||||
}
|
||||
|
||||
/// A video paired with its content hash, ready to be queued for HLS
|
||||
/// playlist generation. Hash is required because all output paths are
|
||||
/// keyed on it; callers that lack a hash (rows mid-backfill) must skip
|
||||
/// the video rather than fabricate one.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoToQueue {
|
||||
pub video_path: PathBuf,
|
||||
pub content_hash: String,
|
||||
pub struct ProcessMessage(pub String, pub Child);
|
||||
|
||||
impl Message for ProcessMessage {
|
||||
type Result = Result<ExitStatus>;
|
||||
}
|
||||
|
||||
impl Handler<ProcessMessage> for StreamActor {
|
||||
type Result = Result<ExitStatus>;
|
||||
|
||||
fn handle(&mut self, msg: ProcessMessage, _ctx: &mut Self::Context) -> Self::Result {
|
||||
trace!("Message received");
|
||||
let mut process = msg.1;
|
||||
let result = process.wait();
|
||||
|
||||
debug!(
|
||||
"Finished waiting for: {:?}. Code: {:?}",
|
||||
msg.0,
|
||||
result
|
||||
.as_ref()
|
||||
.map_or(-1, |status| status.code().unwrap_or(-1))
|
||||
);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub fn playlist_file_for(playlist_dir: &str, video_path: &Path) -> PathBuf {
|
||||
let filename = video_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown");
|
||||
PathBuf::from(format!("{}/{}.m3u8", playlist_dir, filename))
|
||||
}
|
||||
|
||||
/// Sentinel path written next to a would-be playlist when ffmpeg cannot
|
||||
/// transcode the source (e.g. truncated mp4 with no moov atom). Its presence
|
||||
/// causes future scans to skip the file instead of re-running ffmpeg every
|
||||
/// pass. Delete the `.unsupported` file to force a retry.
|
||||
pub fn playlist_unsupported_sentinel(playlist_file: &Path) -> PathBuf {
|
||||
let mut s = playlist_file.as_os_str().to_owned();
|
||||
s.push(".unsupported");
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
pub async fn create_playlist(video_path: &str, playlist_file: &str) -> Result<Child> {
|
||||
if Path::new(playlist_file).exists() {
|
||||
debug!("Playlist already exists: {}", playlist_file);
|
||||
return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists));
|
||||
}
|
||||
|
||||
let result = Command::new("ffmpeg")
|
||||
.arg("-i")
|
||||
.arg(video_path)
|
||||
.arg("-c:v")
|
||||
.arg("h264")
|
||||
.arg("-crf")
|
||||
.arg("21")
|
||||
.arg("-preset")
|
||||
.arg("veryfast")
|
||||
.arg("-hls_time")
|
||||
.arg("3")
|
||||
.arg("-hls_list_size")
|
||||
.arg("0")
|
||||
.arg("-hls_playlist_type")
|
||||
.arg("vod")
|
||||
.arg("-vf")
|
||||
.arg("scale='min(1080,iw)':-2,setsar=1:1")
|
||||
.arg(playlist_file)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
loop {
|
||||
actix::clock::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
if Path::new(playlist_file).exists()
|
||||
|| std::time::Instant::now() - start_time > std::time::Duration::from_secs(5)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn generate_video_thumbnail(path: &Path, destination: &Path) -> std::io::Result<()> {
|
||||
@@ -122,36 +197,16 @@ pub fn generate_image_thumbnail_ffmpeg(path: &Path, destination: &Path) -> std::
|
||||
/// Video stream metadata needed to pick HLS encode settings. Populated by
|
||||
/// a single ffprobe call to avoid spawning multiple subprocesses per video.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct VideoStreamMeta {
|
||||
pub is_h264: bool,
|
||||
struct VideoStreamMeta {
|
||||
is_h264: bool,
|
||||
/// Rotation in degrees (0/90/180/270). Checks both the legacy `rotate`
|
||||
/// stream tag and the modern display-matrix side data.
|
||||
pub rotation: i32,
|
||||
/// Frames per second. Prefers `avg_frame_rate` (handles VFR better than
|
||||
/// `r_frame_rate`, which lies on variable-framerate sources). `None`
|
||||
/// when ffprobe couldn't parse either field — caller picks a fallback.
|
||||
pub frame_rate: Option<f32>,
|
||||
}
|
||||
|
||||
/// Parse ffprobe's rational frame-rate strings (`"30000/1001"`,
|
||||
/// `"60/1"`, `"0/0"`). Rejects 0/0 (ffprobe's "unknown" sentinel),
|
||||
/// non-positive results, and anything wildly out of range so a malformed
|
||||
/// probe can't poison the scrubber's step size.
|
||||
fn parse_ffprobe_rational(s: &str) -> Option<f32> {
|
||||
let (num, den) = s.split_once('/')?;
|
||||
let num: f32 = num.parse().ok()?;
|
||||
let den: f32 = den.parse().ok()?;
|
||||
if den.abs() < f32::EPSILON {
|
||||
return None;
|
||||
}
|
||||
let v = num / den;
|
||||
(v.is_finite() && v > 0.0 && v < 1000.0).then_some(v)
|
||||
rotation: i32,
|
||||
}
|
||||
|
||||
/// Probe video stream metadata in one ffprobe call. Returns default (codec
|
||||
/// unknown, rotation 0, fps None) on any failure — callers fall back to
|
||||
/// transcoding / a default framerate.
|
||||
pub async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
||||
/// unknown, rotation 0) on any failure — callers fall back to transcoding.
|
||||
async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
||||
let output = tokio::process::Command::new("ffprobe")
|
||||
.arg("-v")
|
||||
.arg("error")
|
||||
@@ -159,16 +214,8 @@ pub async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
||||
.arg("v:0")
|
||||
.arg("-print_format")
|
||||
.arg("json")
|
||||
// NOTE: request `stream_side_data_list` (stream-level side data, read
|
||||
// from the moov atom), NOT the bare `side_data_list` section. On modern
|
||||
// ffprobe the latter is the *frame* side-data section, which forces
|
||||
// ffprobe to enumerate every frame — reading the entire mdat over the
|
||||
// network. For non-faststart phone clips on an SMB mount that turned a
|
||||
// metadata probe into a full-file read (tens of seconds per open). The
|
||||
// Display Matrix rotation we need is present at stream level, so this
|
||||
// keeps codec/fps/rotation while reading only the header.
|
||||
.arg("-show_entries")
|
||||
.arg("stream=codec_name,r_frame_rate,avg_frame_rate:stream_tags=rotate:stream_side_data_list")
|
||||
.arg("stream=codec_name:stream_tags=rotate:side_data_list")
|
||||
.arg(video_path)
|
||||
.output()
|
||||
.await;
|
||||
@@ -219,29 +266,12 @@ pub async fn probe_video_stream_meta(video_path: &str) -> VideoStreamMeta {
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
// ffprobe reports frame rates as rational strings like "30000/1001".
|
||||
// Prefer avg_frame_rate (handles VFR) and fall back to r_frame_rate.
|
||||
let frame_rate = stream
|
||||
.get("avg_frame_rate")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(parse_ffprobe_rational)
|
||||
.or_else(|| {
|
||||
stream
|
||||
.get("r_frame_rate")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(parse_ffprobe_rational)
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Probed {}: codec_h264={}, rotation={}°, fps={:?}",
|
||||
video_path, is_h264, rotation, frame_rate
|
||||
"Probed {}: codec_h264={}, rotation={}°",
|
||||
video_path, is_h264, rotation
|
||||
);
|
||||
|
||||
VideoStreamMeta {
|
||||
is_h264,
|
||||
rotation,
|
||||
frame_rate,
|
||||
}
|
||||
VideoStreamMeta { is_h264, rotation }
|
||||
}
|
||||
|
||||
/// Probe the max keyframe interval (GOP) in the first ~30s of a video.
|
||||
@@ -301,17 +331,17 @@ async fn get_max_gop_seconds(video_path: &str) -> Option<f64> {
|
||||
}
|
||||
|
||||
pub struct VideoPlaylistManager {
|
||||
video_dir: PathBuf,
|
||||
playlist_dir: PathBuf,
|
||||
playlist_generator: Addr<PlaylistGenerator>,
|
||||
}
|
||||
|
||||
impl VideoPlaylistManager {
|
||||
pub fn new<P: Into<PathBuf>>(
|
||||
video_dir: P,
|
||||
playlist_dir: P,
|
||||
playlist_generator: Addr<PlaylistGenerator>,
|
||||
) -> Self {
|
||||
Self {
|
||||
video_dir: video_dir.into(),
|
||||
playlist_dir: playlist_dir.into(),
|
||||
playlist_generator,
|
||||
}
|
||||
}
|
||||
@@ -321,68 +351,144 @@ impl Actor for VideoPlaylistManager {
|
||||
type Context = Context<Self>;
|
||||
}
|
||||
|
||||
impl Handler<ScanDirectoryMessage> for VideoPlaylistManager {
|
||||
type Result = ResponseFuture<()>;
|
||||
|
||||
fn handle(&mut self, msg: ScanDirectoryMessage, _ctx: &mut Self::Context) -> Self::Result {
|
||||
let tracer = global_tracer();
|
||||
let mut span = tracer.start("videoplaylistmanager.scan_directory");
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
info!(
|
||||
"Starting scan directory for video playlist generation: {}",
|
||||
msg.directory
|
||||
);
|
||||
|
||||
let playlist_output_dir = self.playlist_dir.clone();
|
||||
let playlist_dir_str = playlist_output_dir.to_str().unwrap().to_string();
|
||||
|
||||
let video_files = WalkDir::new(&msg.directory)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.filter(is_video)
|
||||
.filter(|e| {
|
||||
let playlist = playlist_file_for(&playlist_dir_str, e.path());
|
||||
!playlist.exists() && !playlist_unsupported_sentinel(&playlist).exists()
|
||||
})
|
||||
.collect::<Vec<DirEntry>>();
|
||||
|
||||
let scan_dir_name = msg.directory.clone();
|
||||
let playlist_generator = self.playlist_generator.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
for e in video_files {
|
||||
let path = e.path();
|
||||
let path_as_str = path.to_str().unwrap();
|
||||
debug!(
|
||||
"Sending generate playlist message for path: {}",
|
||||
path_as_str
|
||||
);
|
||||
|
||||
match playlist_generator
|
||||
.send(GeneratePlaylistMessage {
|
||||
playlist_path: playlist_output_dir.to_str().unwrap().to_string(),
|
||||
video_path: PathBuf::from(path),
|
||||
})
|
||||
.await
|
||||
.expect("Failed to send generate playlist message")
|
||||
{
|
||||
Ok(_) => {
|
||||
span.add_event(
|
||||
"Playlist generated",
|
||||
vec![KeyValue::new("video_path", path_as_str.to_string())],
|
||||
);
|
||||
|
||||
debug!(
|
||||
"Successfully generated playlist for file: '{}'",
|
||||
path_as_str
|
||||
);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
debug!("Playlist already exists for '{:?}', skipping", path);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to generate playlist for path '{:?}'. {:?}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span.add_event(
|
||||
"Finished directory scan",
|
||||
vec![KeyValue::new("directory", scan_dir_name.to_string())],
|
||||
);
|
||||
info!(
|
||||
"Finished directory scan of '{}' in {:?}",
|
||||
scan_dir_name,
|
||||
start.elapsed()
|
||||
);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<QueueVideosMessage> for VideoPlaylistManager {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: QueueVideosMessage, _ctx: &mut Self::Context) -> Self::Result {
|
||||
if msg.videos.is_empty() {
|
||||
if msg.video_paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let video_dir = self.video_dir.clone();
|
||||
info!(
|
||||
"Queueing {} videos for HLS playlist generation",
|
||||
msg.video_paths.len()
|
||||
);
|
||||
|
||||
let playlist_output_dir = self.playlist_dir.clone();
|
||||
let playlist_dir_str = playlist_output_dir.to_str().unwrap().to_string();
|
||||
let playlist_generator = self.playlist_generator.clone();
|
||||
|
||||
let mut queued = 0usize;
|
||||
let mut already_present = 0usize;
|
||||
for VideoToQueue {
|
||||
video_path,
|
||||
content_hash,
|
||||
} in msg.videos
|
||||
{
|
||||
let playlist = hls_paths::playlist_for_hash(&video_dir, &content_hash);
|
||||
let sentinel = hls_paths::sentinel_for_hash(&video_dir, &content_hash);
|
||||
if playlist.exists() || sentinel.exists() {
|
||||
already_present += 1;
|
||||
for video_path in msg.video_paths {
|
||||
let playlist = playlist_file_for(&playlist_dir_str, &video_path);
|
||||
if playlist.exists() || playlist_unsupported_sentinel(&playlist).exists() {
|
||||
continue;
|
||||
}
|
||||
debug!(
|
||||
"Queueing playlist generation for {} (hash={})",
|
||||
video_path.display(),
|
||||
short_hash(&content_hash)
|
||||
);
|
||||
let path_str = video_path.to_string_lossy().to_string();
|
||||
debug!("Queueing playlist generation for: {}", path_str);
|
||||
|
||||
playlist_generator.do_send(GeneratePlaylistMessage {
|
||||
playlist_path: playlist_dir_str.clone(),
|
||||
video_path,
|
||||
content_hash,
|
||||
});
|
||||
queued += 1;
|
||||
}
|
||||
info!(
|
||||
"Queue tick: {} queued, {} skipped (playlist or sentinel already on disk)",
|
||||
queued, already_present
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct ScanDirectoryMessage {
|
||||
pub(crate) directory: String,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct QueueVideosMessage {
|
||||
pub videos: Vec<VideoToQueue>,
|
||||
pub video_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "Result<()>")]
|
||||
pub struct GeneratePlaylistMessage {
|
||||
pub video_path: PathBuf,
|
||||
pub content_hash: String,
|
||||
pub playlist_path: String,
|
||||
}
|
||||
|
||||
pub struct PlaylistGenerator {
|
||||
semaphore: Arc<Semaphore>,
|
||||
video_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl PlaylistGenerator {
|
||||
pub(crate) fn new<P: Into<PathBuf>>(video_dir: P) -> Self {
|
||||
pub(crate) fn new() -> Self {
|
||||
// Concurrency is tunable via HLS_CONCURRENCY so operators can dial
|
||||
// it to their hardware: 1 on weak Synology boxes to avoid thermal
|
||||
// throttling, higher on desktops with spare cores.
|
||||
@@ -394,7 +500,6 @@ impl PlaylistGenerator {
|
||||
info!("PlaylistGenerator: concurrency={}", concurrency);
|
||||
PlaylistGenerator {
|
||||
semaphore: Arc::new(Semaphore::new(concurrency)),
|
||||
video_dir: video_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,23 +513,20 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
|
||||
fn handle(&mut self, msg: GeneratePlaylistMessage, _ctx: &mut Self::Context) -> Self::Result {
|
||||
let video_file = msg.video_path.to_str().unwrap().to_owned();
|
||||
let content_hash_str = msg.content_hash.clone();
|
||||
let playlist_path = msg.playlist_path.as_str().to_owned();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let video_dir = self.video_dir.clone();
|
||||
|
||||
let hash_dir = content_hash::hls_dir(&video_dir, &content_hash_str);
|
||||
let playlist_path = hls_paths::playlist_for_hash(&video_dir, &content_hash_str);
|
||||
let sentinel_path = hls_paths::sentinel_for_hash(&video_dir, &content_hash_str);
|
||||
let segment_template = hls_paths::segment_template_for_hash(&video_dir, &content_hash_str);
|
||||
let playlist_file = playlist_path.to_string_lossy().to_string();
|
||||
let segment_pattern = segment_template.to_string_lossy().to_string();
|
||||
let playlist_file = format!(
|
||||
"{}/{}.m3u8",
|
||||
playlist_path,
|
||||
msg.video_path.file_name().unwrap().to_str().unwrap()
|
||||
);
|
||||
|
||||
let tracer = global_tracer();
|
||||
let mut span = tracer
|
||||
.span_builder("playlistgenerator.generate_playlist")
|
||||
.with_attributes(vec![
|
||||
KeyValue::new("video_file", video_file.clone()),
|
||||
KeyValue::new("content_hash", content_hash_str.clone()),
|
||||
KeyValue::new("playlist_file", playlist_file.clone()),
|
||||
])
|
||||
.start(&tracer);
|
||||
@@ -448,7 +550,7 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
)],
|
||||
);
|
||||
|
||||
if playlist_path.exists() {
|
||||
if Path::new(&playlist_file).exists() {
|
||||
debug!("Playlist already exists: {}", playlist_file);
|
||||
span.set_status(Status::error(format!(
|
||||
"Playlist already exists: {}",
|
||||
@@ -457,19 +559,6 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists));
|
||||
}
|
||||
|
||||
// Ensure the shard + hash directory exist. Idempotent — the
|
||||
// dir may already be present from a prior attempt that wrote
|
||||
// a sentinel before being cleared for retry.
|
||||
if let Err(e) = tokio::fs::create_dir_all(&hash_dir).await {
|
||||
error!(
|
||||
"Failed to create HLS hash dir {}: {}",
|
||||
hash_dir.display(),
|
||||
e
|
||||
);
|
||||
span.set_status(Status::error(format!("mkdir failed: {}", e)));
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// One ffprobe call for codec + rotation metadata.
|
||||
let stream_meta = probe_video_stream_meta(&video_file).await;
|
||||
let is_h264 = stream_meta.is_h264;
|
||||
@@ -530,11 +619,16 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
span.add_event("Transcoding to h264", vec![]);
|
||||
}
|
||||
|
||||
// Encode to a .tmp playlist alongside the final inside the
|
||||
// hash dir, so a concurrent scan never sees a half-written
|
||||
// .m3u8 as "done". Segments use the hash-keyed template;
|
||||
// ffmpeg writes them next to the playlist (relative refs).
|
||||
// Encode to a .tmp playlist and explicit segment names so a failed
|
||||
// encode leaves predictable artifacts we can clean up — and so a
|
||||
// concurrent scan doesn't see a half-written .m3u8 as "done".
|
||||
let playlist_tmp = format!("{}.tmp", playlist_file);
|
||||
let video_stem = msg
|
||||
.video_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("video");
|
||||
let segment_pattern = format!("{}/{}_%03d.ts", playlist_path, video_stem);
|
||||
|
||||
let mut cmd = tokio::process::Command::new("ffmpeg");
|
||||
cmd.arg("-y").arg("-i").arg(&video_file);
|
||||
@@ -623,12 +717,12 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
let success = matches!(&ffmpeg_result, Ok(out) if out.status.success());
|
||||
|
||||
if success {
|
||||
if let Err(e) = tokio::fs::rename(&playlist_tmp, &playlist_path).await {
|
||||
if let Err(e) = tokio::fs::rename(&playlist_tmp, &playlist_file).await {
|
||||
error!(
|
||||
"ffmpeg succeeded but rename {} -> {} failed: {}",
|
||||
playlist_tmp, playlist_file, e
|
||||
);
|
||||
cleanup_partial_hls(&hash_dir).await;
|
||||
cleanup_partial_hls(&playlist_tmp, playlist_path.as_str(), video_stem).await;
|
||||
span.set_status(Status::error(format!("rename failed: {}", e)));
|
||||
return Err(e);
|
||||
}
|
||||
@@ -645,17 +739,18 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
Err(e) => format!("ffmpeg failed: {}", e),
|
||||
};
|
||||
error!("ffmpeg failed for {}: {}", video_file, detail);
|
||||
cleanup_partial_hls(&hash_dir).await;
|
||||
if let Err(se) = tokio::fs::write(&sentinel_path, b"").await {
|
||||
cleanup_partial_hls(&playlist_tmp, playlist_path.as_str(), video_stem).await;
|
||||
let sentinel = playlist_unsupported_sentinel(Path::new(&playlist_file));
|
||||
if let Err(se) = tokio::fs::write(&sentinel, b"").await {
|
||||
warn!(
|
||||
"Failed to write playlist sentinel {}: {}",
|
||||
sentinel_path.display(),
|
||||
sentinel.display(),
|
||||
se
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Wrote playlist sentinel {} so future scans skip {}",
|
||||
sentinel_path.display(),
|
||||
sentinel.display(),
|
||||
video_file
|
||||
);
|
||||
}
|
||||
@@ -666,47 +761,29 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the partial playlist (.tmp) and any segment files left behind by
|
||||
/// a failed ffmpeg run. Wipes every non-sentinel file in the hash dir;
|
||||
/// retains the sentinel if one has already been written by an earlier
|
||||
/// caller in the same path (today there is none, but kept defensively so
|
||||
/// the function is safe to call after sentinel write too).
|
||||
async fn cleanup_partial_hls(hash_dir: &Path) {
|
||||
let Ok(mut entries) = tokio::fs::read_dir(hash_dir).await else {
|
||||
/// Delete the temp playlist and any segment files that ffmpeg may have written
|
||||
/// before failing. Called both on ffmpeg error and on rename failure so a
|
||||
/// retry on the next scan starts from a clean slate.
|
||||
async fn cleanup_partial_hls(playlist_tmp: &str, playlist_dir: &str, video_stem: &str) {
|
||||
let _ = tokio::fs::remove_file(playlist_tmp).await;
|
||||
|
||||
let segment_prefix = format!("{}_", video_stem);
|
||||
let Ok(mut entries) = tokio::fs::read_dir(playlist_dir).await else {
|
||||
return;
|
||||
};
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let is_sentinel = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n == hls_paths::UNSUPPORTED_SENTINEL_FILENAME)
|
||||
.unwrap_or(false);
|
||||
if is_sentinel {
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
warn!(
|
||||
"Failed to remove partial HLS file {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
};
|
||||
if name.starts_with(&segment_prefix)
|
||||
&& name.ends_with(".ts")
|
||||
&& let Err(e) = tokio::fs::remove_file(entry.path()).await
|
||||
{
|
||||
warn!("Failed to remove partial segment {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// First 16 chars of a content hash for log lines. Short enough to keep
|
||||
/// log volume sane, long enough that distinct hashes don't collide in
|
||||
/// practice.
|
||||
fn short_hash(hash: &str) -> &str {
|
||||
let end = hash
|
||||
.char_indices()
|
||||
.nth(16)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(hash.len());
|
||||
&hash[..end]
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct GeneratePreviewClipMessage {
|
||||
@@ -831,50 +908,3 @@ impl Handler<GeneratePreviewClipMessage> for PreviewClipGenerator {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_ffprobe_rational;
|
||||
|
||||
#[test]
|
||||
fn parses_common_rational_framerates() {
|
||||
// NTSC 29.97 fps
|
||||
assert!((parse_ffprobe_rational("30000/1001").unwrap() - 29.970_03).abs() < 1e-3);
|
||||
// Plain integer fps
|
||||
assert!((parse_ffprobe_rational("30/1").unwrap() - 30.0).abs() < 1e-6);
|
||||
assert!((parse_ffprobe_rational("60/1").unwrap() - 60.0).abs() < 1e-6);
|
||||
// iPhone slow-mo
|
||||
assert!((parse_ffprobe_rational("240/1").unwrap() - 240.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ffprobe_unknown_sentinel() {
|
||||
// 0/0 is ffprobe's way of saying "I don't know" — must not be
|
||||
// interpreted as 0 fps.
|
||||
assert_eq!(parse_ffprobe_rational("0/0"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_input() {
|
||||
assert_eq!(parse_ffprobe_rational(""), None);
|
||||
assert_eq!(parse_ffprobe_rational("30"), None);
|
||||
assert_eq!(parse_ffprobe_rational("/1"), None);
|
||||
assert_eq!(parse_ffprobe_rational("30/"), None);
|
||||
assert_eq!(parse_ffprobe_rational("abc/def"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_results() {
|
||||
// Negative numerator -> negative fps; meaningless.
|
||||
assert_eq!(parse_ffprobe_rational("-30/1"), None);
|
||||
// Zero numerator -> zero fps; also meaningless for frame stepping.
|
||||
assert_eq!(parse_ffprobe_rational("0/1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range() {
|
||||
// Anything > 1000 fps is almost certainly garbage probe output,
|
||||
// not a real source. (Real high-speed capture maxes near 1 kHz.)
|
||||
assert_eq!(parse_ffprobe_rational("999999/1"), None);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ impl Ffmpeg {
|
||||
/// a hard failure — previously the `parse::<f64>` on empty stdout produced
|
||||
/// "cannot parse float from empty string" and poisoned the preview-clip row
|
||||
/// with status=failed, which the watcher would re-queue every full scan.
|
||||
pub async fn get_duration_seconds(input_file: &str) -> Result<Option<f64>> {
|
||||
async fn get_duration_seconds(input_file: &str) -> Result<Option<f64>> {
|
||||
if let Some(d) = probe_duration(input_file, "format=duration").await? {
|
||||
return Ok(Some(d));
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
//! Path layout for hash-keyed HLS output.
|
||||
//!
|
||||
//! Source-of-truth is [`crate::content_hash::hls_dir`], which gives
|
||||
//! `<video_dir>/<hash[..2]>/<hash>/`. The playlist, the per-segment files,
|
||||
//! and the "ffmpeg refused" sentinel all live inside that directory so a
|
||||
//! `.m3u8` written with relative segment references resolves correctly
|
||||
//! at serve time without any URL rewriting.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::content_hash;
|
||||
|
||||
/// Standard filename for the HLS playlist inside a hash dir. Fixed so
|
||||
/// the URL contract is `playlist.m3u8` regardless of the source video's
|
||||
/// original basename.
|
||||
pub const PLAYLIST_FILENAME: &str = "playlist.m3u8";
|
||||
|
||||
/// Sentinel filename written when ffmpeg refused to transcode the
|
||||
/// source. Presence in the hash dir tells future scans to skip the file
|
||||
/// instead of re-running ffmpeg every tick. Delete to force a retry.
|
||||
pub const UNSUPPORTED_SENTINEL_FILENAME: &str = "playlist.unsupported";
|
||||
|
||||
/// Segment-name template passed to ffmpeg via `-hls_segment_filename`.
|
||||
/// Segments live inside the hash dir; the playlist's relative refs
|
||||
/// resolve to siblings automatically.
|
||||
pub const SEGMENT_TEMPLATE: &str = "segment_%03d.ts";
|
||||
|
||||
/// Path to the HLS playlist for a video identified by content hash.
|
||||
pub fn playlist_for_hash(video_dir: &Path, hash: &str) -> PathBuf {
|
||||
content_hash::hls_dir(video_dir, hash).join(PLAYLIST_FILENAME)
|
||||
}
|
||||
|
||||
/// Path to the unsupported-source sentinel for a hash.
|
||||
pub fn sentinel_for_hash(video_dir: &Path, hash: &str) -> PathBuf {
|
||||
content_hash::hls_dir(video_dir, hash).join(UNSUPPORTED_SENTINEL_FILENAME)
|
||||
}
|
||||
|
||||
/// Absolute path used as ffmpeg's `-hls_segment_filename` value.
|
||||
pub fn segment_template_for_hash(video_dir: &Path, hash: &str) -> PathBuf {
|
||||
content_hash::hls_dir(video_dir, hash).join(SEGMENT_TEMPLATE)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn playlist_path_lives_inside_sharded_hash_dir() {
|
||||
let video = Path::new("/var/video");
|
||||
let p = playlist_for_hash(video, "abcdef0123456789");
|
||||
assert_eq!(
|
||||
p,
|
||||
PathBuf::from("/var/video/ab/abcdef0123456789/playlist.m3u8")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_path_lives_alongside_playlist() {
|
||||
let video = Path::new("/var/video");
|
||||
let s = sentinel_for_hash(video, "abcdef0123456789");
|
||||
assert_eq!(
|
||||
s,
|
||||
PathBuf::from("/var/video/ab/abcdef0123456789/playlist.unsupported")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_template_lives_alongside_playlist() {
|
||||
let video = Path::new("/var/video");
|
||||
let t = segment_template_for_hash(video, "abcdef0123456789");
|
||||
assert_eq!(
|
||||
t,
|
||||
PathBuf::from("/var/video/ab/abcdef0123456789/segment_%03d.ts")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_hashes_yield_distinct_dirs() {
|
||||
let video = Path::new("/var/video");
|
||||
let a = playlist_for_hash(video, "1111aaaa");
|
||||
let b = playlist_for_hash(video, "2222bbbb");
|
||||
assert_ne!(a.parent(), b.parent());
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
//! One-shot retirement of the pre-content-hash HLS output layout.
|
||||
//!
|
||||
//! Before the hash-keyed layout landed, the actor pipeline wrote every
|
||||
//! playlist as `$VIDEO_PATH/<source-basename>.m3u8` with sibling
|
||||
//! `<source-basename>_NNN.ts` segments and a `<source-basename>.m3u8.unsupported`
|
||||
//! sentinel on ffmpeg failure. The new pipeline (see
|
||||
//! [`crate::video::hls_paths`]) puts everything inside a hash-keyed
|
||||
//! subdirectory, so the legacy flat files are orphaned the moment the
|
||||
//! upgraded binary boots — they're not served, not refreshed, and not
|
||||
//! GC'd by the new orphan cleanup (which deliberately ignores anything
|
||||
//! that doesn't sit inside a `<shard>/<hash>/` dir).
|
||||
//!
|
||||
//! This migration runs once on startup. It walks `$VIDEO_PATH` at depth
|
||||
//! 1, deletes every `.m3u8` / `.m3u8.tmp` / `.m3u8.unsupported` / `.ts`
|
||||
//! file, and reports a single info line. It is idempotent — a second
|
||||
//! run finds nothing and reports zero deletions, so it's safe to leave
|
||||
//! wired in across releases until the codebase finally drops the
|
||||
//! module.
|
||||
//!
|
||||
//! Sub-directories under `$VIDEO_PATH` are intentionally left alone:
|
||||
//! every legitimate child of `$VIDEO_PATH` in the new layout is a
|
||||
//! 2-char shard directory holding hash subdirs, and those are managed
|
||||
//! by `cleanup_orphaned_playlists`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use log::{info, warn};
|
||||
|
||||
/// Counters for what the migration did this run.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RetireStats {
|
||||
pub deleted_playlists: usize,
|
||||
pub deleted_segments: usize,
|
||||
pub deleted_sentinels: usize,
|
||||
pub deleted_tmp: usize,
|
||||
pub errors: usize,
|
||||
}
|
||||
|
||||
impl RetireStats {
|
||||
pub fn total_deleted(&self) -> usize {
|
||||
self.deleted_playlists + self.deleted_segments + self.deleted_sentinels + self.deleted_tmp
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete every legacy basename-keyed HLS artifact at the root of
|
||||
/// `video_dir`. Hash dirs (children that are directories) are skipped.
|
||||
/// Returns counts so the caller can log a single line summary.
|
||||
pub fn retire_legacy_hls_output(video_dir: &Path) -> RetireStats {
|
||||
let mut stats = RetireStats::default();
|
||||
|
||||
let read = match std::fs::read_dir(video_dir) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Legacy HLS migration: cannot read {} ({}); skipping",
|
||||
video_dir.display(),
|
||||
e
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in read.flatten() {
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
// Hash shard directories live here in the new layout.
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let bucket = classify(name);
|
||||
let Some(bucket) = bucket else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => match bucket {
|
||||
LegacyKind::Playlist => stats.deleted_playlists += 1,
|
||||
LegacyKind::Segment => stats.deleted_segments += 1,
|
||||
LegacyKind::Sentinel => stats.deleted_sentinels += 1,
|
||||
LegacyKind::Tmp => stats.deleted_tmp += 1,
|
||||
},
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Legacy HLS migration: failed to remove {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
stats.errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if stats.total_deleted() > 0 || stats.errors > 0 {
|
||||
info!(
|
||||
"Legacy HLS migration: deleted {} playlist(s), {} segment(s), {} sentinel(s), {} tmp; {} error(s)",
|
||||
stats.deleted_playlists,
|
||||
stats.deleted_segments,
|
||||
stats.deleted_sentinels,
|
||||
stats.deleted_tmp,
|
||||
stats.errors,
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Legacy HLS migration: nothing to do under {}",
|
||||
video_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
stats
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum LegacyKind {
|
||||
Playlist,
|
||||
Segment,
|
||||
Sentinel,
|
||||
Tmp,
|
||||
}
|
||||
|
||||
/// Decide whether a flat file at `$VIDEO_PATH` root is legacy HLS
|
||||
/// output. Returns `None` for anything else — operator-stashed files,
|
||||
/// new-layout files (which don't live here), etc. — so we don't rm them.
|
||||
fn classify(name: &str) -> Option<LegacyKind> {
|
||||
// Order matters: sentinel and tmp are more specific suffixes that
|
||||
// sit on top of the .m3u8 / .ts extensions, so check them first.
|
||||
if name.ends_with(".m3u8.unsupported") {
|
||||
return Some(LegacyKind::Sentinel);
|
||||
}
|
||||
if name.ends_with(".m3u8.tmp") {
|
||||
return Some(LegacyKind::Tmp);
|
||||
}
|
||||
if name.ends_with(".m3u8") {
|
||||
return Some(LegacyKind::Playlist);
|
||||
}
|
||||
if name.ends_with(".ts") {
|
||||
return Some(LegacyKind::Segment);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn classify_recognises_each_legacy_artifact() {
|
||||
assert!(matches!(
|
||||
classify("IMG_0341.MOV.m3u8"),
|
||||
Some(LegacyKind::Playlist)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify("IMG_0341.MOV_000.ts"),
|
||||
Some(LegacyKind::Segment)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify("IMG_0341.MOV.m3u8.unsupported"),
|
||||
Some(LegacyKind::Sentinel)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify("IMG_0341.MOV.m3u8.tmp"),
|
||||
Some(LegacyKind::Tmp)
|
||||
));
|
||||
|
||||
assert!(classify("README.md").is_none());
|
||||
assert!(classify("ab").is_none()); // shard dir name
|
||||
assert!(classify(".keep").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retire_deletes_legacy_and_leaves_hash_dirs() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// Legacy artifacts at root.
|
||||
fs::write(root.join("IMG_0341.MOV.m3u8"), b"#EXTM3U").unwrap();
|
||||
fs::write(root.join("IMG_0341.MOV_000.ts"), b"\x00").unwrap();
|
||||
fs::write(root.join("IMG_0341.MOV_001.ts"), b"\x00").unwrap();
|
||||
fs::write(root.join("clip.MP4.m3u8.unsupported"), b"").unwrap();
|
||||
fs::write(root.join("partial.m3u8.tmp"), b"").unwrap();
|
||||
|
||||
// New-layout hash dir we must NOT touch.
|
||||
let hash_dir = root.join("ab").join("a".repeat(64));
|
||||
fs::create_dir_all(&hash_dir).unwrap();
|
||||
fs::write(hash_dir.join("playlist.m3u8"), b"#EXTM3U").unwrap();
|
||||
fs::write(hash_dir.join("segment_000.ts"), b"\x00").unwrap();
|
||||
|
||||
// Unrelated file we must NOT touch.
|
||||
fs::write(root.join("README.md"), b"don't touch me").unwrap();
|
||||
|
||||
let stats = retire_legacy_hls_output(root);
|
||||
assert_eq!(stats.deleted_playlists, 1);
|
||||
assert_eq!(stats.deleted_segments, 2);
|
||||
assert_eq!(stats.deleted_sentinels, 1);
|
||||
assert_eq!(stats.deleted_tmp, 1);
|
||||
assert_eq!(stats.errors, 0);
|
||||
|
||||
// Legacy artifacts gone.
|
||||
assert!(!root.join("IMG_0341.MOV.m3u8").exists());
|
||||
assert!(!root.join("IMG_0341.MOV_000.ts").exists());
|
||||
assert!(!root.join("clip.MP4.m3u8.unsupported").exists());
|
||||
assert!(!root.join("partial.m3u8.tmp").exists());
|
||||
// Hash dir untouched.
|
||||
assert!(hash_dir.join("playlist.m3u8").exists());
|
||||
assert!(hash_dir.join("segment_000.ts").exists());
|
||||
// Unrelated file untouched.
|
||||
assert!(root.join("README.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retire_is_idempotent() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
fs::write(root.join("video.mp4.m3u8"), b"#EXTM3U").unwrap();
|
||||
fs::write(root.join("video.mp4_000.ts"), b"\x00").unwrap();
|
||||
|
||||
let first = retire_legacy_hls_output(root);
|
||||
assert_eq!(first.deleted_playlists + first.deleted_segments, 2);
|
||||
|
||||
let second = retire_legacy_hls_output(root);
|
||||
assert_eq!(second.total_deleted(), 0);
|
||||
assert_eq!(second.errors, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retire_handles_missing_dir() {
|
||||
// No panic, no error count blowing up — just a warn + zero stats.
|
||||
let tmp = tempdir().unwrap();
|
||||
let missing = tmp.path().join("does_not_exist");
|
||||
let stats = retire_legacy_hls_output(&missing);
|
||||
assert_eq!(stats.total_deleted(), 0);
|
||||
assert_eq!(stats.errors, 0);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,6 @@ use walkdir::WalkDir;
|
||||
|
||||
pub mod actors;
|
||||
pub mod ffmpeg;
|
||||
pub mod hls_paths;
|
||||
pub mod legacy_migration;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn generate_video_gifs() {
|
||||
|
||||
+137
-264
@@ -22,6 +22,7 @@ use std::time::{Duration, SystemTime};
|
||||
use actix::Addr;
|
||||
use chrono::Utc;
|
||||
use log::{debug, error, info, warn};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::backfill;
|
||||
use crate::content_hash;
|
||||
@@ -32,7 +33,6 @@ use crate::exif;
|
||||
use crate::face_watch;
|
||||
use crate::faces;
|
||||
use crate::file_types;
|
||||
use crate::hls_stats;
|
||||
use crate::libraries;
|
||||
use crate::library_maintenance;
|
||||
use crate::perceptual_hash;
|
||||
@@ -40,34 +40,20 @@ use crate::tags;
|
||||
use crate::tags::SqliteTagDao;
|
||||
use crate::thumbnails;
|
||||
use crate::video;
|
||||
use crate::video::actors::{
|
||||
GeneratePreviewClipMessage, QueueVideosMessage, VideoPlaylistManager, VideoToQueue,
|
||||
};
|
||||
use crate::video::hls_paths;
|
||||
use crate::video::actors::{GeneratePreviewClipMessage, QueueVideosMessage, VideoPlaylistManager};
|
||||
|
||||
/// Clean up orphaned HLS hash directories under `$VIDEO_PATH` whose
|
||||
/// content_hash no longer appears in `image_exif`.
|
||||
///
|
||||
/// Walks `<video_path>/<shard>/<hash>/` — the layout written by the
|
||||
/// hash-keyed `PlaylistGenerator` — and deletes any hash directory whose
|
||||
/// hash isn't in the current DISTINCT set of `image_exif.content_hash`
|
||||
/// values. Empty shard parents are reaped on the same pass.
|
||||
///
|
||||
/// Legacy basename-keyed files at `$VIDEO_PATH` root (from the
|
||||
/// pre-content-hash layout) are left alone here; the one-shot startup
|
||||
/// migration is responsible for retiring those.
|
||||
/// Clean up orphaned HLS playlists and segments whose source videos no longer exist.
|
||||
///
|
||||
/// `libs_lock` is the shared live view of the libraries table — read at the
|
||||
/// top of each cleanup pass so a PATCH /libraries/{id} that disables or
|
||||
/// re-mounts a library is picked up without a restart.
|
||||
pub fn cleanup_orphaned_playlists(
|
||||
libs_lock: Arc<RwLock<Vec<libraries::Library>>>,
|
||||
_excluded_dirs: Vec<String>,
|
||||
excluded_dirs: Vec<String>,
|
||||
library_health: libraries::LibraryHealthMap,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
let video_path_str = dotenv::var("VIDEO_PATH").expect("VIDEO_PATH must be set");
|
||||
let video_path = PathBuf::from(&video_path_str);
|
||||
let video_path = dotenv::var("VIDEO_PATH").expect("VIDEO_PATH must be set");
|
||||
|
||||
// Get cleanup interval from environment (default: 24 hours)
|
||||
let cleanup_interval_secs = dotenv::var("PLAYLIST_CLEANUP_INTERVAL_SECONDS")
|
||||
@@ -75,14 +61,18 @@ pub fn cleanup_orphaned_playlists(
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(86400); // 24 hours
|
||||
|
||||
info!("Starting orphaned HLS cleanup job");
|
||||
info!("Starting orphaned playlist cleanup job");
|
||||
info!(" Cleanup interval: {} seconds", cleanup_interval_secs);
|
||||
info!(" HLS directory: {}", video_path.display());
|
||||
|
||||
let exif_dao: Arc<Mutex<Box<dyn ExifDao>>> = Arc::new(Mutex::new(Box::new(
|
||||
SqliteExifDao::new(),
|
||||
)
|
||||
as Box<dyn ExifDao>));
|
||||
info!(" Playlist directory: {}", video_path);
|
||||
{
|
||||
let libs = libs_lock.read().unwrap_or_else(|e| e.into_inner());
|
||||
for lib in libs.iter() {
|
||||
info!(
|
||||
" Checking sources under '{}' at {}",
|
||||
lib.name, lib.root_path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(cleanup_interval_secs));
|
||||
@@ -93,27 +83,22 @@ pub fn cleanup_orphaned_playlists(
|
||||
let libs: Vec<libraries::Library> =
|
||||
libs_lock.read().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
|
||||
// Safety gate: skip the cleanup cycle if any (enabled)
|
||||
// library is stale. With hash-keyed layout the orphan
|
||||
// decision is a pure DB query, but the upstream
|
||||
// missing-file scan that *removes* image_exif rows already
|
||||
// pauses for stale libraries — so a stale tick can hold
|
||||
// hashes alive that would otherwise have been GC'd. The
|
||||
// safety is then mostly belt-and-suspenders: a hash that
|
||||
// should have been retired is just kept one tick longer.
|
||||
// We'd rather leak a few hash dirs for 24h than wipe a
|
||||
// hash dir whose source was briefly unreachable.
|
||||
// Safety gate: skip the cleanup cycle if any library is
|
||||
// stale. A missing source video on a stale library is
|
||||
// indistinguishable from a transient unmount, and the
|
||||
// cleanup is destructive — we'd rather leak a few playlist
|
||||
// files for a tick than delete one whose source is briefly
|
||||
// unreachable. The cycle re-runs on the next interval.
|
||||
{
|
||||
let guard = library_health.read().unwrap_or_else(|e| e.into_inner());
|
||||
let stale: Vec<String> = libs
|
||||
.iter()
|
||||
.filter(|lib| lib.enabled)
|
||||
.filter(|lib| guard.get(&lib.id).map(|h| !h.is_online()).unwrap_or(false))
|
||||
.map(|lib| lib.name.clone())
|
||||
.collect();
|
||||
if !stale.is_empty() {
|
||||
warn!(
|
||||
"Skipping orphaned-HLS cleanup: {} library(ies) stale: [{}]",
|
||||
"Skipping orphaned-playlist cleanup: {} library(ies) stale: [{}]",
|
||||
stale.len(),
|
||||
stale.join(", ")
|
||||
);
|
||||
@@ -121,129 +106,116 @@ pub fn cleanup_orphaned_playlists(
|
||||
}
|
||||
}
|
||||
|
||||
info!("Running orphaned HLS cleanup");
|
||||
info!("Running orphaned playlist cleanup");
|
||||
let start = std::time::Instant::now();
|
||||
let mut deleted_count = 0;
|
||||
let mut error_count = 0;
|
||||
|
||||
// Snapshot every live content_hash currently in image_exif.
|
||||
// We intentionally don't filter by library here — a hash that
|
||||
// lives in any library is alive, even if the library a given
|
||||
// download attributed it to has since been disabled.
|
||||
let alive_hashes: HashSet<String> = {
|
||||
let context = opentelemetry::Context::new();
|
||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
match dao.list_distinct_content_hashes(&context) {
|
||||
Ok(hashes) => hashes.into_iter().collect(),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to load distinct content hashes; skipping HLS cleanup: {:?}",
|
||||
e
|
||||
// Find all .m3u8 files in VIDEO_PATH
|
||||
let playlists: Vec<PathBuf> = WalkDir::new(&video_path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("m3u8"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.collect();
|
||||
|
||||
info!("Found {} playlist files to check", playlists.len());
|
||||
|
||||
for playlist_path in playlists {
|
||||
// Extract the original video filename from playlist name
|
||||
// Playlist format: {VIDEO_PATH}/{original_filename}.m3u8
|
||||
if let Some(filename) = playlist_path.file_stem() {
|
||||
let video_filename = filename.to_string_lossy();
|
||||
|
||||
// Search for this video file across every configured
|
||||
// library, respecting EXCLUDED_DIRS so we don't
|
||||
// false-resurrect playlists for videos that only
|
||||
// exist inside an excluded subtree. As soon as one
|
||||
// library has a matching source, we're done — the
|
||||
// playlist isn't orphaned.
|
||||
let mut video_exists = false;
|
||||
'libs: for lib in &libs {
|
||||
let effective = lib.effective_excluded_dirs(&excluded_dirs);
|
||||
for entry in image_api::file_scan::walk_library_files(
|
||||
Path::new(&lib.root_path),
|
||||
&effective,
|
||||
) {
|
||||
if let Some(entry_stem) = entry.path().file_stem()
|
||||
&& entry_stem == filename
|
||||
&& file_types::is_video_file(entry.path())
|
||||
{
|
||||
video_exists = true;
|
||||
break 'libs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !video_exists {
|
||||
debug!(
|
||||
"Source video for playlist {} no longer exists, deleting",
|
||||
playlist_path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut deleted_count = 0usize;
|
||||
let mut error_count = 0usize;
|
||||
let mut inspected = 0usize;
|
||||
|
||||
// Walk top-level entries of VIDEO_PATH. Each is either a
|
||||
// legacy basename-keyed `.m3u8` / `.ts` (skip — migration
|
||||
// owns those) or a 2-char shard directory.
|
||||
let read_root = match std::fs::read_dir(&video_path) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"HLS cleanup: failed to read VIDEO_PATH {}: {}",
|
||||
video_path.display(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for shard_entry in read_root.flatten() {
|
||||
let shard_path = shard_entry.path();
|
||||
if !shard_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let shard_name = match shard_path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
if !is_hash_shard(&shard_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hash dirs inside this shard.
|
||||
let read_shard = match std::fs::read_dir(&shard_path) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"HLS cleanup: failed to read shard {}: {}",
|
||||
shard_path.display(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut shard_emptied = true;
|
||||
for hash_entry in read_shard.flatten() {
|
||||
let hash_path = hash_entry.path();
|
||||
if !hash_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
shard_emptied = false;
|
||||
continue;
|
||||
}
|
||||
let Some(hash_name) = hash_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.to_owned())
|
||||
else {
|
||||
shard_emptied = false;
|
||||
continue;
|
||||
};
|
||||
if !is_full_hash(&hash_name) {
|
||||
shard_emptied = false;
|
||||
continue;
|
||||
}
|
||||
inspected += 1;
|
||||
|
||||
if alive_hashes.contains(&hash_name) {
|
||||
shard_emptied = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
debug!(
|
||||
"HLS cleanup: removing orphan hash dir {}",
|
||||
hash_path.display()
|
||||
);
|
||||
match std::fs::remove_dir_all(&hash_path) {
|
||||
Ok(()) => deleted_count += 1,
|
||||
Err(e) => {
|
||||
// Delete the playlist file
|
||||
if let Err(e) = std::fs::remove_file(&playlist_path) {
|
||||
warn!(
|
||||
"Failed to delete orphan hash dir {}: {}",
|
||||
hash_path.display(),
|
||||
"Failed to delete playlist {}: {}",
|
||||
playlist_path.display(),
|
||||
e
|
||||
);
|
||||
error_count += 1;
|
||||
shard_emptied = false;
|
||||
} else {
|
||||
deleted_count += 1;
|
||||
|
||||
// Also try to delete associated .ts segment files
|
||||
// They are typically named {filename}N.ts in the same directory
|
||||
if let Some(parent_dir) = playlist_path.parent() {
|
||||
for entry in WalkDir::new(parent_dir)
|
||||
.max_depth(1)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
{
|
||||
let entry_path = entry.path();
|
||||
if let Some(ext) = entry_path.extension()
|
||||
&& ext.eq_ignore_ascii_case("ts")
|
||||
{
|
||||
// Check if this .ts file belongs to our playlist
|
||||
if let Some(ts_stem) = entry_path.file_stem() {
|
||||
let ts_name = ts_stem.to_string_lossy();
|
||||
if ts_name.starts_with(&*video_filename) {
|
||||
if let Err(e) = std::fs::remove_file(entry_path) {
|
||||
debug!(
|
||||
"Failed to delete segment {}: {}",
|
||||
entry_path.display(),
|
||||
e
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"Deleted segment: {}",
|
||||
entry_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this shard now has no surviving hash dirs, reap
|
||||
// the (empty) shard dir too. remove_dir fails if non-
|
||||
// empty, which is the guard.
|
||||
if shard_emptied {
|
||||
let _ = std::fs::remove_dir(&shard_path);
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Orphaned HLS cleanup completed in {:?}: inspected {} hash dirs, deleted {} orphans, {} errors",
|
||||
"Orphaned playlist cleanup completed in {:?}: deleted {} playlists, {} errors",
|
||||
start.elapsed(),
|
||||
inspected,
|
||||
deleted_count,
|
||||
error_count
|
||||
);
|
||||
@@ -251,24 +223,11 @@ pub fn cleanup_orphaned_playlists(
|
||||
});
|
||||
}
|
||||
|
||||
/// True iff `s` is a two-character lowercase-hex shard prefix.
|
||||
fn is_hash_shard(s: &str) -> bool {
|
||||
s.len() == 2 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// True iff `s` looks like a full blake3 hex digest (64 hex chars).
|
||||
/// Be strict so we don't accidentally rm a non-HLS directory operators
|
||||
/// have stashed under VIDEO_PATH.
|
||||
fn is_full_hash(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
pub fn watch_files(
|
||||
libs_lock: Arc<RwLock<Vec<libraries::Library>>>,
|
||||
playlist_manager: Addr<VideoPlaylistManager>,
|
||||
preview_generator: Addr<video::actors::PreviewClipGenerator>,
|
||||
face_client: crate::ai::face_client::FaceClient,
|
||||
clip_client: crate::ai::clip_client::ClipClient,
|
||||
excluded_dirs: Vec<String>,
|
||||
library_health: libraries::LibraryHealthMap,
|
||||
) {
|
||||
@@ -301,14 +260,6 @@ pub fn watch_files(
|
||||
or APOLLO_API_BASE_URL to enable)"
|
||||
);
|
||||
}
|
||||
if clip_client.is_enabled() {
|
||||
info!(" CLIP semantic search: ENABLED");
|
||||
} else {
|
||||
info!(
|
||||
" CLIP semantic search: DISABLED (set APOLLO_CLIP_API_BASE_URL \
|
||||
or APOLLO_API_BASE_URL to enable)"
|
||||
);
|
||||
}
|
||||
{
|
||||
let libs = libs_lock.read().unwrap_or_else(|e| e.into_inner());
|
||||
for lib in libs.iter() {
|
||||
@@ -337,12 +288,7 @@ pub fn watch_files(
|
||||
));
|
||||
|
||||
let mut last_quick_scan = SystemTime::now();
|
||||
// Initialize to UNIX_EPOCH so the *first* tick is treated as a
|
||||
// full scan. That replaces the legacy startup ScanDirectoryMessage
|
||||
// walk for HLS playlists: every library's existing media gets
|
||||
// checked once at watcher boot, instead of waiting up to
|
||||
// full_interval_secs (1h default) for the first natural full scan.
|
||||
let mut last_full_scan = SystemTime::UNIX_EPOCH;
|
||||
let mut last_full_scan = SystemTime::now();
|
||||
let mut scan_count = 0u64;
|
||||
|
||||
// Per-library cursor for the missing-file scan. Each tick reads
|
||||
@@ -472,21 +418,6 @@ pub fn watch_files(
|
||||
);
|
||||
}
|
||||
|
||||
// CLIP embedding backlog. Independent of face detection —
|
||||
// drain runs whenever CLIP is enabled, even on deploys
|
||||
// that don't have the face engine wired up. Mirrors the
|
||||
// face drain shape (capped per tick, no-op when disabled).
|
||||
if clip_client.is_enabled() {
|
||||
let context = opentelemetry::Context::new();
|
||||
backfill::process_clip_backlog(
|
||||
&context,
|
||||
lib,
|
||||
&clip_client,
|
||||
&exif_dao,
|
||||
&effective_excludes,
|
||||
);
|
||||
}
|
||||
|
||||
// Date-taken backfill: drain rows whose canonical date is
|
||||
// either unresolved or only fs_time-sourced. Independent
|
||||
// of face detection — runs even on deploys that don't
|
||||
@@ -600,16 +531,6 @@ pub fn watch_files(
|
||||
}
|
||||
|
||||
if is_full_scan {
|
||||
// End-of-full-scan HLS readiness summary: log a single
|
||||
// info line + refresh the Prometheus gauges. Skipped on
|
||||
// quick scans because the cost is non-trivial on big
|
||||
// libraries and the data only meaningfully changes on
|
||||
// full passes.
|
||||
let video_dir_str = dotenv::var("VIDEO_PATH").expect("VIDEO_PATH must be set");
|
||||
let stats =
|
||||
hls_stats::compute_and_publish(&libs, &exif_dao, Path::new(&video_dir_str));
|
||||
hls_stats::log_summary(&stats);
|
||||
|
||||
last_full_scan = now;
|
||||
}
|
||||
last_quick_scan = now;
|
||||
@@ -679,18 +600,14 @@ pub fn process_new_files(
|
||||
// Batch query: Get all EXIF data for these files in one query
|
||||
let file_paths: Vec<String> = files.iter().map(|(_, rel_path)| rel_path.clone()).collect();
|
||||
|
||||
// Map of rel_path -> Option<content_hash>. The presence of the key
|
||||
// tells us "row exists"; the Option value carries the hash for the
|
||||
// HLS pipeline so video files without a hash (mid-backfill) skip
|
||||
// this tick rather than fall back to a basename-colliding playlist.
|
||||
let existing_exif: HashMap<String, Option<String>> = {
|
||||
let existing_exif_paths: HashMap<String, bool> = {
|
||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
// Walk is per-library, so scope the lookup so a same-named file
|
||||
// in another library doesn't make this one look already-indexed.
|
||||
match dao.get_exif_batch(&context, Some(library.id), &file_paths) {
|
||||
Ok(exif_records) => exif_records
|
||||
.into_iter()
|
||||
.map(|record| (record.file_path, record.content_hash))
|
||||
.map(|record| (record.file_path, true))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
error!("Error batch querying EXIF data: {:?}", e);
|
||||
@@ -720,7 +637,7 @@ pub fn process_new_files(
|
||||
&& !bare_legacy_thumb_path.exists()
|
||||
&& !thumbnails::unsupported_thumbnail_sentinel(&scoped_thumb_path).exists()
|
||||
&& !thumbnails::unsupported_thumbnail_sentinel(&bare_legacy_thumb_path).exists();
|
||||
let needs_row = !existing_exif.contains_key(relative_path);
|
||||
let needs_row = !existing_exif_paths.contains_key(relative_path);
|
||||
|
||||
if needs_thumbnail || needs_row {
|
||||
new_files_found = true;
|
||||
@@ -879,45 +796,28 @@ pub fn process_new_files(
|
||||
}
|
||||
}
|
||||
|
||||
// Check for videos that need HLS playlists. All output is keyed on
|
||||
// `content_hash` (see `crate::video::hls_paths`), so files whose
|
||||
// `image_exif.content_hash` is still NULL — typically mid-backfill —
|
||||
// are skipped this tick and picked up after the unhashed backlog
|
||||
// drain populates the hash on a subsequent tick. Skipping is the
|
||||
// correct call: queuing without a hash would either fall back to
|
||||
// basename keying (the bug this refactor fixes) or fabricate one.
|
||||
// Check for videos that need HLS playlists
|
||||
let video_path_base = dotenv::var("VIDEO_PATH").expect("VIDEO_PATH must be set");
|
||||
let video_dir = Path::new(&video_path_base);
|
||||
let mut videos_needing_playlists: Vec<VideoToQueue> = Vec::new();
|
||||
let mut hashless_video_count = 0usize;
|
||||
let mut videos_needing_playlists = Vec::new();
|
||||
|
||||
for (file_path, relative_path) in &files {
|
||||
if !file_types::is_video_file(file_path) {
|
||||
continue;
|
||||
}
|
||||
let Some(hash) = existing_exif.get(relative_path).and_then(|h| h.clone()) else {
|
||||
hashless_video_count += 1;
|
||||
continue;
|
||||
};
|
||||
let playlist_path = hls_paths::playlist_for_hash(video_dir, &hash);
|
||||
if playlist_needs_generation(file_path, &playlist_path) {
|
||||
videos_needing_playlists.push(VideoToQueue {
|
||||
video_path: file_path.clone(),
|
||||
content_hash: hash,
|
||||
});
|
||||
for (file_path, _relative_path) in &files {
|
||||
if file_types::is_video_file(file_path) {
|
||||
// Construct expected playlist path
|
||||
let playlist_filename =
|
||||
format!("{}.m3u8", file_path.file_name().unwrap().to_string_lossy());
|
||||
let playlist_path = Path::new(&video_path_base).join(&playlist_filename);
|
||||
|
||||
// Check if playlist needs (re)generation
|
||||
if playlist_needs_generation(file_path, &playlist_path) {
|
||||
videos_needing_playlists.push(file_path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hashless_video_count > 0 {
|
||||
debug!(
|
||||
"Watcher tick for '{}': skipped {} video(s) with NULL content_hash (will retry after backfill)",
|
||||
library.name, hashless_video_count
|
||||
);
|
||||
}
|
||||
|
||||
// Send queue request to playlist manager
|
||||
if !videos_needing_playlists.is_empty() {
|
||||
playlist_manager.do_send(QueueVideosMessage {
|
||||
videos: videos_needing_playlists,
|
||||
video_paths: videos_needing_playlists,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1062,33 +962,6 @@ mod tests {
|
||||
assert!(playlist_needs_generation(&video, &playlist));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hash_shard_accepts_only_two_hex_chars() {
|
||||
assert!(is_hash_shard("ab"));
|
||||
assert!(is_hash_shard("00"));
|
||||
assert!(is_hash_shard("FF")); // ASCII hexdigit covers upper-case too
|
||||
assert!(!is_hash_shard("a"));
|
||||
assert!(!is_hash_shard("abc"));
|
||||
assert!(!is_hash_shard("zz"));
|
||||
assert!(!is_hash_shard(""));
|
||||
assert!(!is_hash_shard("a/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_full_hash_accepts_only_64_hex_chars() {
|
||||
let h64 = "a".repeat(64);
|
||||
assert!(is_full_hash(&h64));
|
||||
let mixed = format!("ab{}", "0".repeat(62));
|
||||
assert!(is_full_hash(&mixed));
|
||||
assert!(!is_full_hash(&"a".repeat(63)));
|
||||
assert!(!is_full_hash(&"a".repeat(65)));
|
||||
assert!(!is_full_hash(&format!("z{}", "a".repeat(63))));
|
||||
// Defends against operator stashing e.g. ".tmp" or "Plex" under
|
||||
// VIDEO_PATH — neither passes the full-hash gate.
|
||||
assert!(!is_full_hash(".tmp"));
|
||||
assert!(!is_full_hash("Plex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlist_needs_generation_true_when_video_missing_metadata() {
|
||||
// Video doesn't exist; metadata fails for it. Falls through to the
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"Worcester": "Wuster",
|
||||
"Spokane": "Spo can",
|
||||
"wsl": "W S L",
|
||||
"sql": "sequel",
|
||||
"api": "A P I",
|
||||
"US": "U S",
|
||||
"Dr.": "Doctor",
|
||||
"St.": "Saint",
|
||||
"blvd": "boulevard",
|
||||
"vs.": "versus",
|
||||
"etc.": "et cetera"
|
||||
}
|
||||
Reference in New Issue
Block a user