10 KiB
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→ caddy →: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/homecloudNEXTCLOUD_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/homecloudis bind-mounted intobackend,worker-light,worker-watcher,worker-visionas/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 inusers.nextcloud_app_password_enc) so Nextcloud'soc_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_usernameoverrides the default OIDCpreferred_username.dtoro(mule-image) maps toadmin(Nextcloud) — don't assume username equality.- Surviving SourceRoots in DB:
Photos→/nextcloud-users/admin/files/Photos;Memories→/nextcloud-users/admin/files/Memories(both owned bydtoro). Usermulihasnextcloud_username=mulibackfilled 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.shin 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 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.
Related
- Nextcloud (114) — source of truth for photo libraries
- Authentik (124)
- Caddy (121)
- DNS
- Auto-deploy
- Gitea (104)
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: onlyActiveHeapCardandHeaderstay pinned now. Editable + readonly content scroll together in oneflex-1 overflow-y-autoregion beneath them. PhotoInfoPanel.tsxdropped itsh-full/ innerflex-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. - Second pass: the editable form (filename / title / notes / rating / color / flag) got the same treatment under an outer "Edit"
Collapsibleso the panel is now two equal collapsible groups below the title strip. Dropped the X (clear-selection) button from the Header; Esc and grid-empty-area-click still clear.
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:
- Backend out of dev mode.
docker-compose.ymlcommand:was runninguvicorn … --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. - 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 PILImage.open(src_path)as the last fallback in both_generate_proxy_webp(routers/photos.py) andprocess_raw_image(tasks/thumbs.py). ~1,300 iPhone DNGs were 415-ing on every detail view; now decode in <1s via PIL. - Reconcile DB with disk (renamed-folder case).
prune_missing_photoswas skipping all 4,154 orphaned photo rows under SourceRootTaco and Muli - 2024 onward(renamed in Nextcloud toPhoto Archive 2004-2024) because the leaf path didn't resolve and the code assumed "drive unmounted → must skip". Added_sr_state()to classify aspresent/renamed(parent mount fine, leaf gone) /unmounted(parent inaccessible). Onlyunmountedstill skips. Two stale source roots logged with a clearer hint pointing atPOST /api/v1/library/maintenance/prune-missing. User has not yet been asked to run that — endpoint is ready when they are. - Frontend page size + idle polling.
usePhotosQuery.tswas first-fetchingper_page=500(slow paint + 500 thumb requests at once). Split intoPER_PAGE_INITIAL=100for first paint,PER_PAGE_BACKGROUND=500for the cursor-chain prefetch. Idle polling forscan-statusandworker-status(useScanActivity.ts,ScanProgress.tsx) bumped from 10s/15s to 30s/30s while idle; active cadence (2s/3s) unchanged. - Partial index on
photos. Default list queryWHERE NOT is_trashed AND NOT is_hidden ORDER BY taken_at DESC NULLS LAST, id DESC LIMIT Nwas doing a seq-scan + top-N heapsort (~25ms standalone, worse under concurrency). Added migration0017_photos_list_indexcreatingix_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.