From f00a309a24e22cb5613db0782742301ac4695956 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Tue, 29 Sep 2026 13:02:49 -0400 Subject: [PATCH] dev/e2e: disposable server for app e2e / agent UI testing `dev/e2e/e2e.sh up` runs an isolated ImageApi (port 8090, own build dir, db, thumbnails, HLS) next to a real local server, serving a copy of a synthetic fixture library. Every fixture image has its path, date and scenario burned into the pixels; manifest.json records expected dates, GPS, cameras, tags and counts. `reset` restores the baseline. - LOCAL_BIND_URL makes the hardcoded localhost:8088 listener configurable (default unchanged; empty disables it) so a second instance can start. - create_user bin: the server has no registration route; idempotent. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 10 +- dev/e2e/.gitignore | 2 + dev/e2e/README.md | 78 ++++++++++ dev/e2e/e2e.sh | 256 ++++++++++++++++++++++++++++++++ dev/e2e/gen_fixtures.py | 319 ++++++++++++++++++++++++++++++++++++++++ src/bin/create_user.rs | 40 +++++ src/main.rs | 20 ++- 7 files changed, 719 insertions(+), 6 deletions(-) create mode 100644 dev/e2e/.gitignore create mode 100644 dev/e2e/README.md create mode 100644 dev/e2e/e2e.sh create mode 100644 dev/e2e/gen_fixtures.py create mode 100644 src/bin/create_user.rs diff --git a/CLAUDE.md b/CLAUDE.md index fac6136..765d08d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,10 +65,18 @@ cargo clippy cargo fix ``` +### E2E dev server +`dev/e2e/e2e.sh up` starts an isolated instance (port 8090, own build dir, db and +synthetic fixture library) alongside any real server, for app e2e / agent UI +testing. `reset` restores the baseline. See `dev/e2e/README.md`. + ### Utility Binaries ```bash # Two-phase cleanup: resolve missing files and validate file types cargo run --bin cleanup_files -- --base-path /path/to/media --database-url ./database.db + +# Create a login user (no HTTP registration route; idempotent) +cargo run --bin create_user -- ``` ## Architecture Overview @@ -602,7 +610,7 @@ Module map: 6. Initialize AppState with Actix actors 7. Set up Prometheus metrics (`imageserver_image_total`, `imageserver_video_total`) 8. Scan directory for videos and queue HLS processing -9. Start HTTP server on `BIND_URL` + localhost:8088 +9. Start HTTP server on `BIND_URL` + `LOCAL_BIND_URL` (default `localhost:8088`; empty disables it) ## Testing Patterns diff --git a/dev/e2e/.gitignore b/dev/e2e/.gitignore new file mode 100644 index 0000000..b6ccc7f --- /dev/null +++ b/dev/e2e/.gitignore @@ -0,0 +1,2 @@ +.work/ +env.local diff --git a/dev/e2e/README.md b/dev/e2e/README.md new file mode 100644 index 0000000..709bd62 --- /dev/null +++ b/dev/e2e/README.md @@ -0,0 +1,78 @@ +# e2e dev server + +A disposable ImageApi instance with a known, synthetic media library, for +end-to-end and agent-driven UI testing of the SynologyFileViewer app. + +```bash +dev/e2e/e2e.sh up # build + fixtures + reset + start; blocks until ingest is done +dev/e2e/e2e.sh reset # back to the pristine baseline (after tests mutate things) +dev/e2e/e2e.sh stop +``` + +Log in from the app with **server** `http://10.0.2.2:8090` (Android +emulator) or `http://127.0.0.1:8090` (Expo web / `adb reverse tcp:8090 tcp:8090`), +**user** `e2e`, **password** `E2eTest#2026pass`. + +## Isolation + +It runs next to a real local server without touching it: + +| | real server | e2e server | +|---|---|---| +| exe | `target/debug/image-api.exe` | `dev/e2e/.work/bin/image-api-e2e.exe` (built in `target/e2e`) | +| port | `BIND_URL` + `localhost:8088` | `127.0.0.1:8090` only (`LOCAL_BIND_URL=` disables the 8088 bind) | +| data | your `.env` | `dev/e2e/.work/state/` (db, thumbs, hls, gifs, previews, media) | +| AI | configured | off — Ollama points at a closed port | + +`stop` kills by image name `image-api-e2e.exe`, so it can't hit the real +server. Everything under `.work/` is gitignored and safe to delete. + +## Fixtures + +`gen_fixtures.py` renders every image with its relative path, date and +scenario burned into the pixels — a screenshot tells you exactly which +fixture is on screen. `.work/state/manifest.json` lists every item with its +expected date, GPS, camera, tags and duplicate relationship, plus expected +counts; assert against it rather than hardcoding. + +| Folder | Covers | +|---|---| +| `2019/2019-07-04 Beach Day`, `2021/Paris Trip`, `2022/Hiking` | EXIF dates, GPS (map), cameras, tags, mixed orientation | +| `2022/Hiking/summit.mp4`, `Videos/` | landscape + portrait video, HLS, container dates | +| `2023/Birthday` | dated, no GPS | +| `Grid Stress` (60) | scrolling / pagination | +| `Screenshots` | date from filename only (`filename` source), PNG | +| `Undated` | no date anywhere → pinned mtime (`fs_time` source) | +| `On This Day` | memories: today's month/day in 2018/2020/2024 | +| `private` | `EXCLUDED_DIRS` — never indexed | +| `Duplicates` | byte-identical copy of `2021/Paris Trip/IMG_1000.jpg` | +| `Empty Album` | folder with no media | + +"On This Day" is relative to the generation date — run +`e2e.sh fixtures --force && e2e.sh reset` on a later day to keep memories +populated. Tags from the manifest are seeded through the API on first start. + +To test against real photos, `e2e.sh fixtures --import /path/to/folder` +copies a folder into `media/imported/` (the source is only read). + +## Extra configuration + +Put server env overrides in `dev/e2e/env.local` (gitignored); it's appended +to the generated `.env` on every `reset`. E.g. to exercise AI insights +against the local llama-swap: + +``` +LLM_BACKEND=llamacpp +LLAMA_SWAP_URL=http://localhost:9292/v1 +``` + +Env for the script itself: `E2E_PORT`, `E2E_BIND` (`0.0.0.0` for a physical +device on Wi-Fi), `E2E_USER`, `E2E_PASS`. + +## Notes for agents + +- WSL2 (NAT networking) can't reach Windows' `127.0.0.1`; use `curl.exe`, + or `e2e.sh api GET 'photos?path=&recursive=true'` / `e2e.sh token`. +- Sort values are lowercase: `sort=datetakendesc`. +- Rebuild after server changes with `e2e.sh build` (restarts if running). +- Server log: `.work/server.log`. diff --git a/dev/e2e/e2e.sh b/dev/e2e/e2e.sh new file mode 100644 index 0000000..25ccc1a --- /dev/null +++ b/dev/e2e/e2e.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Disposable ImageApi instance for end-to-end / agent-driven UI testing. +# +# Runs a second, fully isolated server next to any real one: its own build +# dir (target/e2e — a running target/debug exe is file-locked on Windows), +# its own process name (image-api-e2e.exe, so `stop` can never touch the +# real server), its own port, database, thumbnails and HLS output. The +# media library is a *copy* of the pristine fixtures, so tests may upload, +# move and delete freely; `reset` restores the known baseline. +# +# Written for WSL driving the Windows toolchain (cargo.exe, curl.exe, +# ffmpeg.exe). WSL2's default NAT networking can't reach Windows' +# 127.0.0.1, hence curl.exe for every HTTP call. +# +# Usage: dev/e2e/e2e.sh (see `help`) +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +WORK="$HERE/.work" +FIXTURES="$WORK/fixtures" +STATE="$WORK/state" +BIN_DIR="$WORK/bin" +EXE_NAME="image-api-e2e.exe" +LOG="$WORK/server.log" + +E2E_PORT="${E2E_PORT:-8090}" +# 127.0.0.1 is reachable from the Windows browser (Expo web), the Android +# emulator (as 10.0.2.2) and USB devices via `adb reverse`. Set +# E2E_BIND=0.0.0.0 for a physical device over Wi-Fi. +E2E_BIND="${E2E_BIND:-127.0.0.1}" +E2E_USER="${E2E_USER:-e2e}" +E2E_PASS="${E2E_PASS:-E2eTest#2026pass}" +URL="http://127.0.0.1:$E2E_PORT" + +win() { wslpath -w "$1"; } +log() { printf '\033[1;34m[e2e]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[e2e]\033[0m %s\n' "$*" >&2; exit 1; } + +is_running() { + tasklist.exe /FI "IMAGENAME eq $EXE_NAME" /NH 2>/dev/null | grep -qi "$EXE_NAME" +} + +cmd_build() { + log "building bins into target/e2e" + (cd "$REPO" && cargo.exe build --target-dir target/e2e --bin image-api --bin create_user) + mkdir -p "$BIN_DIR" + # Run from a copy so rebuilding never fights a running (locked) exe. + local was_running=0 + if is_running; then was_running=1; cmd_stop; fi + cp "$REPO/target/e2e/debug/image-api.exe" "$BIN_DIR/$EXE_NAME" + cp "$REPO/target/e2e/debug/create_user.exe" "$BIN_DIR/create_user.exe" + if [ "$was_running" = 1 ]; then cmd_start; fi +} + +cmd_fixtures() { + local force=0 import=() + while [ $# -gt 0 ]; do + case "$1" in + --force) force=1 ;; + --import) import=(--import "$2"); force=1; shift ;; + *) die "unknown fixtures option: $1" ;; + esac + shift + done + if [ -f "$FIXTURES/manifest.json" ] && [ "$force" = 0 ]; then + log "fixtures exist (generated $(grep -o '"generated_on": "[^"]*"' "$FIXTURES/manifest.json" | cut -d'"' -f4)); --force to regenerate" + return + fi + log "generating fixtures" + python3 "$HERE/gen_fixtures.py" "$FIXTURES" "${import[@]}" +} + +write_env() { + cat > "$STATE/.env" <> "$STATE/.env" + fi +} + +cmd_reset() { + [ -f "$FIXTURES/manifest.json" ] || cmd_fixtures + is_running && cmd_stop + log "resetting state (db, thumbs, hls, media copy)" + rm -rf "$STATE" + mkdir -p "$STATE"/{thumbs,hls,gifs,previews} + cp -a "$FIXTURES/media" "$STATE/media" + cp "$FIXTURES/manifest.json" "$STATE/manifest.json" + write_env + [ -x "$BIN_DIR/create_user.exe" ] || cmd_build + (cd "$STATE" && "$BIN_DIR/create_user.exe" "$E2E_USER" "$E2E_PASS") +} + +login() { + curl.exe -sf -X POST "$URL/login" -H "Content-Type: application/json" \ + -d "{\"username\":\"$E2E_USER\",\"password\":\"$E2E_PASS\"}" 2>/dev/null \ + | grep -o '"token":"[^"]*"' | cut -d'"' -f4 +} + +api() { # api METHOD PATH [JSON]; reuses $E2E_TOKEN when set (login is a bcrypt verify) + local tok="${E2E_TOKEN:-$(login)}" + # /dev/null || echo 0 +} + +seed_tags() { + local E2E_TOKEN; E2E_TOKEN="$(login)" + python3 - "$STATE/manifest.json" <<'EOF' | while IFS=$'\t' read -r path tag; do +import json, sys +for tag, paths in json.load(open(sys.argv[1]))["tags"].items(): + for p in paths: + print(f"{p}\t{tag}") +EOF + api POST "image/tags" "{\"file_name\":\"$path\",\"tag_name\":\"$tag\"}" >/dev/null \ + || log "warn: failed to tag $path" + done + touch "$STATE/.tags_seeded" +} + +cmd_start() { + is_running && { log "already running at $URL"; return; } + [ -f "$STATE/.env" ] || cmd_reset + [ -x "$BIN_DIR/$EXE_NAME" ] || cmd_build + log "starting $EXE_NAME on $E2E_BIND:$E2E_PORT" + # cwd = state dir: the server reads .env from its working directory. + # Redirect the whole subshell, not just the exe: an inherited stdout + # would keep a caller's pipe (`e2e.sh up | tail`) open until shutdown. + (cd "$STATE" && exec setsid nohup "$BIN_DIR/$EXE_NAME") >"$LOG" 2>&1 & + disown + for _ in $(seq 1 60); do + [ -n "$(login)" ] && break + is_running || die "server exited; see $LOG" + sleep 1 + done + [ -n "$(login)" ] || die "server did not accept logins within 60s; see $LOG" + + [ -f "$STATE/.tags_seeded" ] || { log "seeding tags"; seed_tags; } + + local want have=0 + want="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["counts"]["indexed"])' "$STATE/manifest.json")" + log "waiting for ingest ($want indexable fixtures)" + for _ in $(seq 1 90); do + have="$(indexed_count)" + [ "$have" -ge "$want" ] && break + sleep 2 + done + if [ "$have" -ge "$want" ]; then + log "ready: $have files indexed" + else + log "warn: only $have/$want indexed after 180s (still ingesting?); see $LOG" + fi + cmd_info +} + +cmd_stop() { + if is_running; then + log "stopping $EXE_NAME" + taskkill.exe /IM "$EXE_NAME" /F >/dev/null + for _ in $(seq 1 20); do is_running || break; sleep 0.5; done + else + log "not running" + fi +} + +cmd_status() { + if is_running; then + echo "running $URL (indexed: $(indexed_count))" + else + echo "stopped" + fi +} + +cmd_info() { + cat < $URL + Login: $E2E_USER / $E2E_PASS + Manifest (expected content): $STATE/manifest.json + Log: $LOG + +EOF +} + +cmd_help() { + cat < + + up build (if needed) + fixtures (if needed) + reset + start + build cargo build into target/e2e and install the e2e exe copies + fixtures [--force] [--import DIR] + generate the pristine fixture library (--import copies a + real folder in as media/imported/; the source is read-only) + reset stop, wipe db/thumbs/hls, re-copy media, recreate user + start | stop | restart | status + token print a fresh JWT for the e2e user + api METHOD PATH [JSON] + authenticated request, e.g. api GET 'photos?path=&recursive=true' + logs tail -f the server log + info connection details + +Env: E2E_PORT (8090), E2E_BIND (127.0.0.1), E2E_USER, E2E_PASS. +Extra server env: dev/e2e/env.local (appended to the generated .env on reset). +EOF +} + +case "${1:-help}" in + up) shift; [ -x "$BIN_DIR/$EXE_NAME" ] || cmd_build; cmd_fixtures; cmd_reset; cmd_start ;; + build) cmd_build ;; + fixtures) shift; cmd_fixtures "$@" ;; + reset) cmd_reset ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_stop; cmd_start ;; + status) cmd_status ;; + token) login ;; + api) shift; api "$@" ;; + logs) tail -f "$LOG" ;; + info) cmd_info ;; + help|-h|--help) cmd_help ;; + *) die "unknown command: $1 (try help)" ;; +esac diff --git a/dev/e2e/gen_fixtures.py b/dev/e2e/gen_fixtures.py new file mode 100644 index 0000000..e92c090 --- /dev/null +++ b/dev/e2e/gen_fixtures.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Generate the synthetic e2e media library. + +Every image is rendered with its own relative path, date and scenario +burned into the pixels, so an agent looking at a screenshot can tell +exactly which fixture is on screen. EXIF (dates, GPS, camera) is stamped +in a single exiftool batch. A `manifest.json` next to the media records +what was generated and what the server should derive from it, so tests +can assert against it instead of hardcoding counts. + +The harness runs in WSL but the tools (ffmpeg.exe / exiftool.exe) are +Windows binaries, so every path handed to them goes through `wslpath -w`. + +Usage: gen_fixtures.py [--import DIR] + /media/ the pristine library (never served directly) + /manifest.json what's in it +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import shutil +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from pathlib import Path + +FFMPEG = shutil.which("ffmpeg.exe") or shutil.which("ffmpeg") +EXIFTOOL = shutil.which("exiftool.exe") or shutil.which("exiftool") +# drawtext needs an explicit font on Windows ffmpeg builds. The colon after +# the drive letter must be escaped inside a filter argument. +FONT = "C\\:/Windows/Fonts/consola.ttf" + +PLACES = { + "santa_monica": (34.0100, -118.4960), + "paris": (48.8584, 2.2945), + "yosemite": (37.7456, -119.5936), + "tokyo": (35.6595, 139.7005), +} + +PALETTE = [ + "0x1f4e79", "0x7a3e9d", "0x2e7d32", "0xb71c1c", "0xe65100", + "0x006064", "0x4e342e", "0x283593", "0x827717", "0xad1457", +] + + +@dataclass +class Item: + rel: str # path relative to the library root, forward slashes + kind: str # "image" | "video" + size: tuple[int, int] = (1600, 1200) + taken: dt.datetime | None = None # written to EXIF / video creation_time + gps: str | None = None # key into PLACES + camera: tuple[str, str] | None = None # (Make, Model) + mtime: dt.datetime | None = None # pinned file mtime (undated fallback) + duration: int = 0 # video seconds + tags: list[str] = field(default_factory=list) + scenario: str = "" + copy_of: str | None = None # byte-identical duplicate of another item + + def label_date(self) -> str: + if self.taken: + return self.taken.strftime("%Y-%m-%d %H:%M") + return "no EXIF date" + + +def win(p: Path) -> str: + return subprocess.check_output(["wslpath", "-w", str(p)], text=True).strip() + + +def build_items(today: dt.date) -> list[Item]: + items: list[Item] = [] + canon = ("Canon", "Canon EOS R5") + iphone = ("Apple", "iPhone 12") + pixel = ("Google", "Pixel 7") + + # Dated album with GPS — map pins, date grouping, tag search. + for i in range(6): + items.append(Item( + f"2019/2019-07-04 Beach Day/beach_{i + 1:02d}.jpg", "image", + taken=dt.datetime(2019, 7, 4, 10 + i, 15), gps="santa_monica", + camera=canon, tags=["beach", "summer"], scenario="dated album + GPS", + )) + # Multi-day trip; mix of orientations. + for i in range(8): + items.append(Item( + f"2021/Paris Trip/IMG_{1000 + i}.jpg", "image", + size=(1200, 1600) if i % 3 == 0 else (1600, 1200), + taken=dt.datetime(2021, 5, 12 + i // 3, 9 + i, 30), gps="paris", + camera=iphone, tags=["travel", "paris"], scenario="trip, mixed orientation", + )) + for i in range(5): + items.append(Item( + f"2022/Hiking/hike_{i + 1:02d}.jpg", "image", + taken=dt.datetime(2022, 8, 20, 7 + i, 0), gps="yosemite", + camera=pixel, tags=["outdoors"], scenario="album with a video", + )) + items.append(Item( + "2022/Hiking/summit.mp4", "video", size=(1280, 720), duration=6, + taken=dt.datetime(2022, 8, 20, 12, 30), tags=["outdoors"], + scenario="landscape video (HLS)", + )) + # No GPS, tagged people-ish album. + for i in range(6): + items.append(Item( + f"2023/Birthday/party_{i + 1:02d}.jpg", "image", + taken=dt.datetime(2023, 11, 3, 18, 5 * i), camera=iphone, + tags=["family"], scenario="dated, no GPS", + )) + # Enough items to force scrolling / pagination in the grid. + start = dt.datetime(2020, 1, 3, 12, 0) + for i in range(60): + items.append(Item( + f"Grid Stress/photo_{i + 1:03d}.jpg", "image", size=(800, 600), + taken=start + dt.timedelta(days=6 * i), camera=pixel, + scenario="bulk grid / scroll", + )) + # Dates that only exist in the filename (the memories.rs regexes). + items += [ + Item("Screenshots/Screenshot_20220315-101500.png", "image", size=(1080, 2340), + scenario="date from filename (YYYYMMDD-HHMMSS), PNG, no EXIF"), + Item("Screenshots/IMG_20210704_123000.jpg", "image", + scenario="date from filename (YYYYMMDD_HHMMSS), no EXIF"), + Item("Screenshots/2020-02-29_08-00-00.jpg", "image", + scenario="date from filename (leap day), no EXIF"), + ] + # No date anywhere: falls back to the pinned mtime. + for i in range(3): + items.append(Item( + f"Undated/scan_{i + 1:02d}.jpg", "image", + mtime=dt.datetime(2015, 3, 1 + i, 12, 0), + scenario="no EXIF, no filename date (mtime fallback)", + )) + # "On this day" — same month/day as generation date in earlier years. + for year in (2018, 2020, 2024): + taken = _same_day(today, year, 14) + items.append(Item( + f"On This Day/otd_{year}.jpg", "image", taken=taken, gps="tokyo", + camera=canon, scenario=f"memories: on this day ({year})", + )) + # Excluded via EXCLUDED_DIRS=/private — never indexed, must not appear anywhere. + items.append(Item( + "private/otd_hidden.jpg", "image", taken=_same_day(today, 2019, 9), + scenario="EXCLUDED_DIRS: never indexed or shown", + )) + # Portrait video, filename-dated. + items.append(Item( + "Videos/VID_20230610_150000.mp4", "video", size=(720, 1280), duration=4, + taken=dt.datetime(2023, 6, 10, 15, 0), scenario="portrait video", + )) + # Byte-identical duplicate for duplicate detection. + items.append(Item( + "Duplicates/IMG_1000 copy.jpg", "image", copy_of="2021/Paris Trip/IMG_1000.jpg", + scenario="exact duplicate of 2021/Paris Trip/IMG_1000.jpg", + )) + return items + + +def _same_day(today: dt.date, year: int, hour: int) -> dt.datetime: + # Feb 29 in a non-leap year would be invalid; fall back to Feb 28. + try: + return dt.datetime(year, today.month, today.day, hour, 0) + except ValueError: + return dt.datetime(year, today.month, 28, hour, 0) + + +def render(item: Item, idx: int, media: Path) -> None: + out = media / item.rel + out.parent.mkdir(parents=True, exist_ok=True) + w, h = item.size + color = PALETTE[idx % len(PALETTE)] + lines = [item.rel, item.label_date(), item.scenario] + # Consolas glyphs are ~0.55em wide; shrink so the longest line fits. + longest = max(len(line) for line in lines) + fs = max(12, min(min(w, h) // 22, int(w * 0.92 / (longest * 0.55)))) + text = ",".join( + f"drawtext=fontfile='{FONT}':text='{_esc(line)}':fontcolor=white:" + f"fontsize={fs}:x=(w-text_w)/2:y=(h/2)+({n - 1}*{int(fs * 1.6)})" + f":box=1:boxcolor=black@0.45:boxborderw=8" + for n, line in enumerate(lines) + ) + if item.kind == "image": + cmd = [ + FFMPEG, "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", + f"gradients=s={w}x{h}:c0={color}:c1=0x101010:seed={idx}:duration=1", + "-vf", text, "-frames:v", "1", + ] + cmd += ["-q:v", "3"] if out.suffix.lower() == ".jpg" else [] + else: + cmd = [ + FFMPEG, "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", f"testsrc2=s={w}x{h}:r=30:d={item.duration}", + "-f", "lavfi", "-i", f"sine=frequency={300 + idx * 20}:d={item.duration}", + "-vf", text, "-c:v", "libx264", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-shortest", + ] + if item.taken: + cmd += ["-metadata", f"creation_time={item.taken.isoformat()}Z"] + subprocess.run(cmd + [win(out)], check=True) + + +def _esc(s: str) -> str: + # drawtext text escaping: backslash, colon, quote, percent, comma. + for ch in ("\\", ":", "'", "%", ","): + s = s.replace(ch, "\\" + ch) + return s + + +def stamp_exif(items: list[Item], media: Path, work: Path) -> None: + args: list[str] = [] + for it in items: + if it.kind != "image" or it.copy_of or not (it.taken or it.gps or it.camera): + continue + if not it.rel.lower().endswith(".jpg"): + continue + if it.taken: + stamp = it.taken.strftime("%Y:%m:%d %H:%M:%S") + args += [f"-DateTimeOriginal={stamp}", f"-CreateDate={stamp}"] + if it.camera: + args += [f"-Make={it.camera[0]}", f"-Model={it.camera[1]}"] + if it.gps: + lat, lon = PLACES[it.gps] + args += [ + f"-GPSLatitude={abs(lat)}", f"-GPSLatitudeRef={'N' if lat >= 0 else 'S'}", + f"-GPSLongitude={abs(lon)}", f"-GPSLongitudeRef={'E' if lon >= 0 else 'W'}", + ] + args += [win(media / it.rel), "-execute"] + argfile = work / "exif_args.txt" + argfile.write_text("\n".join(args) + "\n", encoding="utf-8") + subprocess.run( + [EXIFTOOL, "-@", win(argfile), "-common_args", "-q", "-overwrite_original", + "-charset", "filename=utf8"], + check=True, + ) + argfile.unlink() + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("out_dir", type=Path) + ap.add_argument("--import", dest="import_dir", type=Path, + help="also copy this folder (read-only source) into media/imported/") + a = ap.parse_args() + if not FFMPEG or not EXIFTOOL: + print("ffmpeg(.exe) and exiftool(.exe) must be on PATH", file=sys.stderr) + return 1 + + out: Path = a.out_dir.resolve() + media = out / "media" + if media.exists(): + shutil.rmtree(media) + media.mkdir(parents=True) + (media / "Empty Album").mkdir() # edge case: folder with no media + + today = dt.date.today() + items = build_items(today) + originals = [(i, it) for i, it in enumerate(items) if not it.copy_of] + with ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(lambda p: render(p[1], p[0], media), originals)) + stamp_exif(items, media, out) + + for it in items: + if it.copy_of: + (media / it.rel).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(media / it.copy_of, media / it.rel) + # Pin mtimes last: exiftool rewrites (and so touches) the files. + for it in items: + when = it.mtime or it.taken or dt.datetime(2024, 1, 1) + ts = when.timestamp() + os.utime(media / it.rel, (ts, ts)) + + imported = 0 + if a.import_dir: + dest = media / "imported" + shutil.copytree(a.import_dir, dest) # copy: the source is never touched + imported = sum(1 for p in dest.rglob("*") if p.is_file()) + + tags: dict[str, list[str]] = {} + for it in items: + for t in it.tags: + tags.setdefault(t, []).append(it.rel) + manifest = { + "generated_on": today.isoformat(), + "note": "'On This Day' fixtures match generated_on's month/day; regenerate " + "(e2e.sh fixtures --force) on another day to keep memories populated.", + "counts": { + "images": sum(1 for it in items if it.kind == "image"), + "videos": sum(1 for it in items if it.kind == "video"), + "total": len(items), + # What the server should index: EXCLUDED_DIRS is skipped at ingest. + "indexed": sum(1 for it in items if not it.rel.startswith("private/")) + imported, + "imported_files": imported, + }, + "excluded_dirs": ["/private"], + "places": {k: {"lat": v[0], "lon": v[1]} for k, v in PLACES.items()}, + "tags": tags, + "items": [ + { + "path": it.rel, "kind": it.kind, "scenario": it.scenario, + "width": it.size[0], "height": it.size[1], + "taken": it.taken.isoformat() if it.taken else None, + "gps": it.gps, "camera": it.camera[1] if it.camera else None, + "tags": it.tags, "duplicate_of": it.copy_of, + } + for it in items + ], + } + (out / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + print(f"generated {len(items)} fixtures ({imported} imported) in {media}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/bin/create_user.rs b/src/bin/create_user.rs new file mode 100644 index 0000000..da06ac7 --- /dev/null +++ b/src/bin/create_user.rs @@ -0,0 +1,40 @@ +//! Create a login user. The server has no registration route, so this is +//! how a fresh database (e.g. the `dev/e2e` harness) gets its first account. +//! +//! Idempotent: an existing username is left untouched and reported, so the +//! harness can re-run it on every reset. Runs pending migrations first so it +//! works against a database the server has never opened. +//! +//! Usage: `create_user ` (reads `DATABASE_URL` from +//! `.env` / the environment like the server does). + +use anyhow::{Result, bail}; +use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; +use image_api::connect; +use image_api::database::{SqliteUserDao, UserDao}; + +const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); + +fn main() -> Result<()> { + dotenv::dotenv().ok(); + + let args: Vec = std::env::args().skip(1).collect(); + let [username, password] = args.as_slice() else { + bail!("usage: create_user "); + }; + + connect() + .run_pending_migrations(MIGRATIONS) + .map_err(|e| anyhow::anyhow!("failed to run migrations: {e}"))?; + + let mut dao = SqliteUserDao::new(); + if dao.user_exists(username) { + println!("user '{username}' already exists; leaving it unchanged"); + return Ok(()); + } + match dao.create_user(username, password) { + Some(_) => println!("created user '{username}'"), + None => bail!("failed to create user '{username}'"), + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index ab71b3a..3a7200b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -285,7 +285,7 @@ fn main() -> std::io::Result<()> { // 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 server = HttpServer::new(move || { let user_dao = SqliteUserDao::new(); let favorites_dao = SqliteFavoriteDao::new(); let tag_dao = SqliteTagDao::default(); @@ -471,10 +471,20 @@ fn main() -> std::io::Result<()> { .app_data::>(Data::new(app_data.insight_generator.clone())) .wrap(prometheus.clone()) }) - .bind(dotenv::var("BIND_URL").unwrap())? - .bind("localhost:8088")? - .run() - .await + .bind(dotenv::var("BIND_URL").unwrap())?; + + // Secondary loopback listener. Defaults to localhost:8088; set + // LOCAL_BIND_URL to move it, or to an empty string to skip it — + // required to run a second instance (e.g. the dev/e2e harness) + // alongside one that already owns 8088. + let local_bind = env::var("LOCAL_BIND_URL").unwrap_or_else(|_| "localhost:8088".into()); + let server = if local_bind.trim().is_empty() { + server + } else { + server.bind(local_bind.trim())? + }; + + server.run().await }) }