Heap card stays pinned at the top, editable form + readonly metadata share a single scroll area, and the four readonly sections are now wrapped in one outer Metadata collapsible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
120 lines
9.8 KiB
Markdown
120 lines
9.8 KiB
Markdown
# 120 — `mule-images`
|
||
|
||
Hosts `mule-image` / "mulita" — the photos app at `photos.hubris.network`. Auto-deploys from `dtoro/mule-image` on `git push origin main`.
|
||
|
||
## At a glance
|
||
- **Hostname:** `mule-images`
|
||
- **IP:** `192.168.8.136`
|
||
- **Privilege:** privileged
|
||
- **Resources:** 4 cores / 8 GiB RAM / 60 GiB rootfs
|
||
- **Mounts:** `/mnt/library` ↔ `/mnt/library`
|
||
- **Public hostname:** [`photos.hubris.network`](../infrastructure/dns.md) → [caddy](121-caddy.md) → `:3000` (frontend)
|
||
|
||
## Stack (`/opt/mule-image`)
|
||
|
||
`/opt/mule-image` IS the working tree of `dtoro/mule-image`. Compose at `/opt/mule-image/docker-compose.yml`. Services:
|
||
|
||
| Service | Port | Notes |
|
||
| ----------------- | ------ | ----- |
|
||
| frontend | 3000 | Reverse-proxied by Caddy |
|
||
| backend | 8001 | FastAPI |
|
||
| worker-vision | — | ML scan worker |
|
||
| worker-light | — | Lightweight worker |
|
||
| worker-watcher | — | FS watcher |
|
||
| db | (pg) | pgvector |
|
||
| redis | (rd) | queue |
|
||
|
||
`.env` is **untracked** — `git checkout .env` will wipe it. Holds:
|
||
- `PHOTO_DIRS=/mnt/library/images/`
|
||
- `NEXTCLOUD_USERS_HOST_PATH=/mnt/library/homecloud`
|
||
- `NEXTCLOUD_BASE_URL=https://cloud.hubris.network`
|
||
- OIDC client secret + scopes
|
||
- `SECRET_KEY` (generated)
|
||
|
||
## Nextcloud-rooted libraries (since 2026-04-26)
|
||
|
||
Photo libraries live under each user's Nextcloud `files/` tree, NOT in `/mnt/library/images/*`.
|
||
- `/mnt/library/homecloud` is bind-mounted into `backend`, `worker-light`, `worker-watcher`, `worker-vision` as `/nextcloud-users`. Each NC user is `/nextcloud-users/<nc_user>/files/`.
|
||
- Reads use that bind directly.
|
||
- Mutations (upload, delete, rename, move) dispatch through `services/nextcloud_dav.py` (HTTP Basic auth, per-user app password Fernet-encrypted in `users.nextcloud_app_password_enc`) so Nextcloud's `oc_filecache`, trashbin, comments, and desktop-sync clients stay coherent.
|
||
- Photo copy + cross-system moves return 501 with a "use Nextcloud's web UI" hint — defer until needed.
|
||
- `users.nextcloud_username` overrides the default OIDC `preferred_username`. **`dtoro` (mule-image) maps to `admin` (Nextcloud)** — don't assume username equality.
|
||
- Surviving SourceRoots in DB: `Photos` → `/nextcloud-users/admin/files/Photos`; `Memories` → `/nextcloud-users/admin/files/Memories` (both owned by `dtoro`). User `muli` has `nextcloud_username=muli` backfilled but no SourceRoot yet.
|
||
- Pre-migration DB dump: `/root/snapshots/mulita-pre-nc-migration-20260426-075132.dump` (11 MB) on the host.
|
||
|
||
## Authentication (since 2026-04-22)
|
||
|
||
Native OIDC via Authentik. Code in `backend/app/auth_oidc.py`, routes `/api/v1/auth/oidc/{login,callback}`. Authentik side:
|
||
- OAuth2/OIDC Provider, client ID `fCuHew48ONTskDjUKnMTZjFbVXuHwvQqTScQRNQ1`
|
||
- App slug `mule-image`
|
||
- Redirect URI: `https://photos.hubris.network/api/v1/auth/oidc/callback`
|
||
|
||
Backend container needs `extra_hosts: auth.hubris.network:192.168.8.175` via `docker-compose.override.yml` (gitignored). Otherwise Authlib's metadata fetch fails with `SSL: CERTIFICATE_VERIFY_FAILED: self-signed certificate` (it ends up at a random public host because LXC DNS resolves the public IONOS A record).
|
||
|
||
Caddyfile stays plain `reverse_proxy 192.168.8.136:3000` — no forward-auth, no `/api/*` bypass needed.
|
||
|
||
## Auto-deploy
|
||
|
||
Push to `dtoro/mule-image` `main` → gitea webhook → `http://192.168.8.136:9797/deploy` → `mule-deploy-webhook.service`:
|
||
- Validates HMAC against `/etc/mule-deploy/secret`
|
||
- Filters to `refs/heads/main`
|
||
- Runs `/opt/mule-deploy/deploy.sh` in a daemon thread (returns 202 immediately — docker builds exceed gitea's request timeout)
|
||
- `git pull --ff-only` + `docker compose up -d --build` + `docker image prune -f`
|
||
|
||
Deploy tooling is **outside** the app repo: `/opt/mule-deploy/{deploy.sh,webhook.py}`, secret at `/etc/mule-deploy/secret`, unit at `/etc/systemd/system/mule-deploy-webhook.service`. Same shape as the Caddy + Artifacto pipelines. Gitea webhook id 6.
|
||
|
||
`app.ini` `ALLOWED_HOST_LIST` on [gitea](104-gitea.md) includes `192.168.8.136`.
|
||
|
||
Logs: `pct exec 120 -- journalctl -u mule-deploy-webhook -f`.
|
||
Manual deploy: `pct exec 120 -- /opt/mule-deploy/deploy.sh`.
|
||
|
||
For pushes from inside the LXC, gitea creds at `/etc/mule-deploy/git-credentials` (mode 600) — same token as `/etc/caddy-deploy/git-credentials` on [caddy](121-caddy.md).
|
||
|
||
## Related
|
||
- [Nextcloud (114)](114-nextcloud.md) — source of truth for photo libraries
|
||
- [Authentik (124)](124-authentik.md)
|
||
- [Caddy (121)](121-caddy.md)
|
||
- [DNS](../infrastructure/dns.md)
|
||
- [Auto-deploy](../infrastructure/auto-deploy.md)
|
||
- [Gitea (104)](104-gitea.md)
|
||
|
||
## Changelog
|
||
|
||
### 2026-05-10 — right sidebar restructure (heap pinned, single scroll, collapsible Metadata)
|
||
|
||
The right sidepanel had three stacked flex regions: `<ActiveHeapCard />`, `<Header />`, and `<PhotoInfoPanel />` — with `PhotoInfoPanel` carrying its own internal scroll. That left the editable fields (filename, title, notes, rating, color, flag) stuck above the readonly metadata scroll, effectively two scroll boundaries on one sidebar.
|
||
|
||
- Moved the scroll boundary up to `RightSidebar.tsx`: only `ActiveHeapCard` and `Header` stay pinned now. Editable + readonly content scroll together in one `flex-1 overflow-y-auto` region beneath them.
|
||
- `PhotoInfoPanel.tsx` dropped its `h-full` / inner `flex-1 overflow-y-auto`.
|
||
- The four readonly sections (Tags / Basic Info / Camera / Location) are now wrapped in a single outer "Metadata" `Collapsible`. Default expanded, one click hides the whole block. Sub-sections stay individually collapsible.
|
||
|
||
`PreviewView` reuses `RightSidebar` under an `<aside class="overflow-hidden">`, so the change applies in both the grid sidebar and the preview overlay.
|
||
|
||
### 2026-05-10 — `photos.hubris.network` perf sweep
|
||
|
||
User reported slow load. Five fixes shipped, in order:
|
||
|
||
1. **Backend out of dev mode.** `docker-compose.yml` `command:` was running `uvicorn … --reload` (single worker, file-watcher). Page loads fan out ~15 parallel API calls; they all serialized on one event loop. → `--workers 2 --proxy-headers`. Two uvicorn worker procs now.
|
||
2. **iPhone Apple ProRAW / Linear DNG decode fixed.** rawpy 0.26.1 + LibRaw 0.22 rejects Apple Linear DNG (`Photometric Interpretation: Linear Raw`, 8-bit) as "Unsupported file format or not RAW file". Those files are TIFF containers with developed RGB inside and have no embedded preview to fall back to. Added PIL `Image.open(src_path)` as the last fallback in both `_generate_proxy_webp` (routers/photos.py) and `process_raw_image` (tasks/thumbs.py). ~1,300 iPhone DNGs were 415-ing on every detail view; now decode in <1s via PIL.
|
||
3. **Reconcile DB with disk (renamed-folder case).** `prune_missing_photos` was skipping all 4,154 orphaned photo rows under SourceRoot `Taco and Muli - 2024 onward` (renamed in Nextcloud to `Photo Archive 2004-2024`) because the leaf path didn't resolve and the code assumed "drive unmounted → must skip". Added `_sr_state()` to classify as `present` / `renamed` (parent mount fine, leaf gone) / `unmounted` (parent inaccessible). Only `unmounted` still skips. Two stale source roots logged with a clearer hint pointing at `POST /api/v1/library/maintenance/prune-missing`. User has not yet been asked to run that — endpoint is ready when they are.
|
||
4. **Frontend page size + idle polling.** `usePhotosQuery.ts` was first-fetching `per_page=500` (slow paint + 500 thumb requests at once). Split into `PER_PAGE_INITIAL=100` for first paint, `PER_PAGE_BACKGROUND=500` for the cursor-chain prefetch. Idle polling for `scan-status` and `worker-status` (`useScanActivity.ts`, `ScanProgress.tsx`) bumped from 10s/15s to 30s/30s while idle; active cadence (2s/3s) unchanged.
|
||
5. **Partial index on `photos`.** Default list query `WHERE NOT is_trashed AND NOT is_hidden ORDER BY taken_at DESC NULLS LAST, id DESC LIMIT N` was doing a seq-scan + top-N heapsort (~25ms standalone, worse under concurrency). Added migration `0017_photos_list_index` creating `ix_photos_list_visible` (partial index on the sort key, restricted to visible rows). EXPLAIN now shows an Index Only Scan → **24.7ms → 0.097ms**, ~250× speedup.
|
||
|
||
**Deploy gotcha (repeated).** The `docker compose up -d --build` in `deploy.sh` does not recreate the backend when only runtime config (`command:` or env-only changes) or migration files change without changing the Dockerfile-bake outcome. Forced recreation with `docker compose up -d --no-deps --force-recreate backend` twice this session. **Action item:** add `--force-recreate` (or at least `--force-recreate backend` when backend files changed) to `/opt/mule-deploy/deploy.sh` so future deploys are reliable.
|
||
|
||
**Data drift still outstanding.** 4,154 photo rows + 1 unregistered folder (`Photo Archive 2004-2024`) on disk that's not a SourceRoot. The reconcile endpoints now work — user decides when to call them. The new folder needs to be added as a SourceRoot via the Settings UI before its files will be indexed.
|
||
|
||
**Proxy cache still empty** (`mule-image_proxies_data` volume is 4 KB). Pre-generating ~500–1,500 WebP proxies for non-web-safe formats would make first-open of every RAW/HEIC photo instant. Deferred — needs a one-shot script and the disk-space tradeoff isn't worth it until the data-drift reconcile happens first.
|
||
|
||
### 2026-04-28 — wiki entry created
|
||
Initial documentation.
|
||
|
||
### 2026-04-26 — Nextcloud-rooted libraries shipped
|
||
Bind `/mnt/library/homecloud` into the workers, reads via filesystem, writes via WebDAV. `users.nextcloud_username` override field added; `dtoro → admin` mapping. Surviving SourceRoots cleaned up to NC paths.
|
||
|
||
### 2026-04-22 — native OIDC via Authentik
|
||
Authlib-based code in `backend/app/auth_oidc.py`. `extra_hosts` override for `auth.hubris.network` in compose override (gitignored).
|
||
|
||
### 2026-04-21 — auto-deploy pipeline shipped
|
||
Webhook receiver at `:9797`, async deploy returning 202. Mirrors caddy-conf / gitea-customizations.
|