After the hard-remove SourceRoot endpoint shipped, two real-world FK
issues surfaced (parent_id self-FK and cross-source-root children) and
one tz-aware datetime issue on PATCH /photos/{id}. All three fixed in
the same session. Trash icon also picks up a spinner during the multi-
second cascade.
Deploy.sh's --force-recreate flag isn't actually recreating reliably;
flagged as open infra question.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 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 — Two cascade-delete + datetime fixes after the hard-remove shipped
The first cut of delete_nextcloud_source_root blew up with folders_parent_id_fkey violations on the first real test (Taco and Muli - 2024 onward, 35 folders / 4,158 photos). Two iterations to get it right:
- NULL
parent_idbefore deleting Folders. Folders have a self-referentialparent_idFK with noON DELETErule; postgres checks the constraint per row regardless of insertion order, so a flatDELETE FROM folders WHERE id IN (...)of the whole subtree fails on the parents whose children appear later in the same statement. Fixed with anUPDATE folders SET parent_id = NULL WHERE id IN (folder_ids)first. - Widen the NULL UPDATE to cross-source-root children. A "Leóns 1st Year" SourceRoot at
.../Taco and Muli - 2024 onward/Leóns 1st Yearhad its own folder rows whoseparent_idpointed into the Taco SourceRoot's hierarchy. The first patch only NULLedparent_idfor folders whoseidwas in the delete set; the Leóns folders weren't in that set so they kept their references and the FK still tripped. Fix:UPDATE folders SET parent_id = NULL WHERE parent_id IN (folder_ids)— kills any incoming reference into the delete set, internal or external.
After both fixes: DELETE /api/v1/nextcloud/source-roots/{id} for Taco and Muli - 2024 onward cleared 4,158 photos and 35 folders in a single request and returned 200. UI swaps the trash icon for a Loader2 spinner while the request is in flight (removeRoot.isPending && removeRoot.variables === r.id) so the multi-second cascade is visible.
Separate fix in the same session: PATCH /api/v1/photos/{id} returned 500 with can't subtract offset-naive and offset-aware datetimes when the frontend sent a tz-aware ISO string for taken_at (the datetime-local input is supposed to be naive but real-world locales / paste flows occasionally include +02:00). The DB column is timestamp without time zone, so asyncpg refused to bind. Normalize on the server with astimezone(timezone.utc).replace(tzinfo=None) if tzinfo is not None.
Deploy infra learning: the new --force-recreate in deploy.sh does NOT reliably recreate containers on every push — saw two consecutive deploys leave the backend at the previous StartedAt despite a fresh image. Manual docker compose up -d --no-deps --force-recreate <svc> after deploy still races with whatever compose state the auto-deploy left mid-flight, twice landing the stack in a half-broken state (orphaned <id>_mulita-backend rename containers, db / redis stopped). Recovery: docker compose down && docker compose up -d. Open question — --force-recreate may need to be replaced with something more explicit.
2026-05-10 — Hard-remove Nextcloud SourceRoot + reliable delete sync
DELETE /api/v1/nextcloud/source-roots/{id} was a soft-deactivate (is_active=false) — the trash icon in Settings only hid the SourceRoot from active queries while leaving every Folder + Photo row in the DB forever. Re-adding the same path resurrected ghosts; prune-missing reported zero deletes for the soft-removed entry because the cleanup code skipped inactive source roots.
Endpoint now hard-deletes the SourceRoot, every Folder under it (chunked at 500), and every Photo in those folders. photo_tags and heap_photos cascade automatically via ON DELETE CASCADE on the join tables. FolderShare uses a stringly-typed folder_id (no FK) so cleaned manually for both folder_type='folder' and folder_type='source_root'. Returns {deleted_photos, deleted_folders} so the UI can toast a count. Files in Nextcloud are untouched.
Sync side: the watch_folders celery task in backend/app/tasks/scan.py already detected filesystem deletions and soft-marked photos is_discarded=true, but the global task_soft_time_limit=300 in app/tasks/celery.py was killing the watcher every five minutes and dropping every FS event during the restart window. The soft_time_limit=None on the decorator was being interpreted as "use worker default" rather than "unlimited". Override to soft_time_limit=0, time_limit=0 (Celery convention for unbounded). Backstop: a new discard_missing_photos() in app/services/cleanup.py — soft variant of prune_missing_photos, walks every _sr_state == 'present' source root and flips is_discarded=true on Photo rows whose file is gone. Wired as discard_missing_photos_beat celery task scheduled every 30 minutes via beat_schedule on the celery app. Beat runs in-process on worker-watcher (--beat flag in docker-compose.yml) — the watcher is already a Redis-locked singleton so no need for a separate beat container.
Manual POST /api/v1/library/maintenance/prune-missing remains the hard-delete path for when the user wants to permanently drop orphan rows; the new beat job only soft-discards (file gone → mule-image trash, restorable).
2026-05-10 — OIDC auto-redirect on LoginPage
OIDC_ENABLED=true was already set in .env, so the LoginPage rendered a "Sign in with Authentik" button next to the password form. With a single trusted IdP and a logged-in Authentik session, that extra click was friction without upside.
LoginPage.tsx now reads /auth/config on mount and, if OIDC is on, immediately navigates to the OIDC login URL. Authentik recognizes the existing session and bounces back through the callback with no user interaction. Two escape hatches: ?password=1 in the URL forces the password form, and a skipAutoSso sessionStorage flag (set by AuthContext.logout and by the OIDC callback's error branch) suppresses the next auto-redirect so logouts actually log out and OIDC failures surface their error instead of looping. While the redirect is in flight the page shows "Signing in with Authentik..." plus a "Use password instead" link.
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. - Third pass: split editable vs read-only between the two groups consistently.
TagsEditorandTakenAtEditorhad been buried inside the readonly Metadata sub-sections —Tagsas its own Section, taken-at wedged intoBasic Infobetween size/dims and the filepath. Moved both into the Edit collapsible, ordered identification → description → categorization: Filename · Title · Date Taken · Notes · Tags · Rating · Color · Flag. Metadata now holds only readonly:Basic Info(size, dims, path),Camera,Location.
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 — fixed (with caveat). The original docker compose up -d --build in deploy.sh did not reliably recreate containers when only runtime config (command:, env-only) or migration files changed; image hash would change but compose treated the existing container as "current enough". Bit three times this session before /opt/mule-deploy/deploy.sh was updated to docker compose up -d --build --force-recreate. Trade-off accepted: an extra restart cycle on deploys where nothing user-visible changed.
Caveat: the first auto-deploy after the flag flip raced with my own earlier manual docker compose up -d --no-deps --force-recreate frontend and landed the stack in a half-broken state — mulita-frontend got stuck under a renamed temp container, several services dropped off mule-image_mulita-network, frontend nginx restarted in a loop with host not found in upstream "backend". Fixed by docker compose down && docker compose up -d. Don't issue a manual --force-recreate on a single service while the auto-deploy webhook is also expected to fire — let the deploy own the lifecycle.
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.