The watchfiles-based watcher works but duplicates Nextcloud's own
notion of "this file changed." NC has a webhook_listeners app that
can POST file events to an external URL. This adds the mule side of
that handshake.
- POST /api/v1/internal/nc-webhook authenticates a Bearer token
(NEXTCLOUD_WEBHOOK_SECRET, hmac.compare_digest) and dispatches the
same scan_folder / handle_file_deletion machinery the watcher used.
- Handles NodeCreated, NodeWritten, NodeDeleted, NodeRenamed.
Renamed is mapped to delete-old + scan-new-parent. Maps NC's
/admin/files/... path to the bind-mounted /nextcloud-users/admin/files/...
- backend/scripts/register_nc_webhooks.py is the idempotent
registrar: lists existing webhooks, deletes any pointing at the
target URL, then POSTs four fresh ones via OCS.
- Sets the env passthrough on backend + all workers in compose so
the same secret is available wherever the registrar might run.
watch_folders stays in place for now — webhooks become primary, the
watcher is a belt-and-suspenders fallback. Drop the watcher in a
follow-up once webhooks are proven reliable on this NC instance.
`GET /api/v1/photos/{id}/original` returned 200 with the full body for
every request, even ones with a Range header. Browsers refuse to play
<video> they can't seek and surface the failure as "format not
supported" — most visible on .mov / .mp4 over 5–10 MB.
Now parses `Range: bytes=START-END` (and bytes=-N for the tail), emits
206 with Content-Range, streams the slice in 1 MB chunks. Full body
responses advertise Accept-Ranges so the browser knows to retry with a
Range on the next request.
Single-range only — multipart/byteranges is rare in practice and not
worth the complexity.
mule-image was generating and storing three WebP sizes per photo in
/data/thumbs while Nextcloud already keeps its own previews for the
same source files. Frontend thumbnail requests now proxy NC's
/index.php/core/preview keyed by the photo's Nextcloud fileid,
authenticated with the owner's encrypted app password.
- new column photos.nextcloud_fileid (alembic 0018) plus an index
- get_preview_async + fetch_fileid helpers in nextcloud_dav.py
- thumb route proxies NC primary, falls back to /data/thumbs (legacy
rows / NC unreachable) so a single-file revert restores the old path
- extract_metadata caches the fileid on first run for new photos
- generate_thumbnails now writes only medium since the vision worker
still loads it from disk; small + large drop out of the worker path
- backend/scripts/backfill_nextcloud_fileid.py for one-shot population
of existing rows: docker exec mulita-backend python -m scripts.backfill_nextcloud_fileid
X-Mule-Thumb-Source response header marks each request 'nextcloud' or
'disk' for observability while the rollout settles.
Two copies of IMG_1234.jpg sitting in different folders looked
identical on the duplicates grid — same filename, same dimensions,
same Best heuristic. The user had no way to pick which copy to keep
without opening each in the preview overlay.
Backend: include filepath in the per-member payload from
GET /api/v1/library/duplicates/groups (was filename-only).
Frontend: a black 65% strip at the bottom of every duplicate
thumbnail showing the parent folder name (the actual discriminator
when filenames match), with the full filepath surfaced via the
native title tooltip on hover. The dimensions chip moves from
bottom-left to top-left so the bottom strip can run edge-to-edge.
memberToPhoto stops faking filepath=filename (a years-old workaround
that broke any code path needing the real path); the synthetic Photo
the grid hands to PhotoThumbnail now carries the real filepath.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Title (user_title) field hadn't earned its place in the sidebar
form — the underlying column stays on the model but the editable
row + its draft state + commit handler are gone.
Bulk Notes: a textarea in the multi-photo bulk panel that replaces
user_notes across the whole selection with one string. Apply commits;
Clear empties the draft without committing. New backend bulk action
'set_notes' validates the value is a string (or null/empty to clear)
and writes to every photo in the selection in one go. Wired through
the standard useBulkPhotoMutations optimistic-patch path, so the
photo cache flips immediately and rolls back on error.
user_notes added to the shared Photo type so patchPhotos accepts the
field; previously it was only on PhotoInfoPanel's local PhotoDetails.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/v1/photos rejected ?date_from=2026-04-10 with 422 because
pydantic v2's datetime parser doesn't accept date-only strings. The
frontend has been padding date_to with T23:59:59 forever to make the
upper bound inclusive, but date_from went out as a bare YYYY-MM-DD,
so every date-range filter request 422'd and the grid showed nothing.
Frontend: pad date_from with T00:00:00 the same way date_to gets
T23:59:59 — symmetry, and pydantic v2 accepts the full form.
Backend: change date_from/date_to to Optional[str] and parse with
datetime.fromisoformat in the handler. fromisoformat accepts both
bare dates ('2026-04-10' -> midnight) and full ISO strings, so any
older client that still sends a date-only value continues to work.
Tz-aware values get coerced to naive UTC before binding (matches the
taken_at column's shape and the same
fix applied to PATCH /photos/{id} earlier today). Bad input returns
400 with a clear message instead of pydantic's 422.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The taken_at extractor walked four EXIF fields in order: DateTimeOriginal,
CreateDate, MediaCreateDate, ModifyDate. The last one is set every time
a file is re-saved (Lightroom export, EXIF strip, batch resize), so any
photo whose original capture metadata was lost during editing ended up
labeled 'exif' with the *edit* date instead of the shoot date.
Changes:
- SubSecDateTimeOriginal at the top of the list (sub-second precision,
often carries OffsetTime).
- QuickTime:CreateDate added next to MediaCreateDate.
- ModifyDate dropped from the trusted list entirely.
- When no trusted EXIF date is present, fall back to guess_date_from_path
(already used for has_date_warning) and tag taken_at_source='path'.
Better than filesystem mtime, which on Nextcloud-mounted libraries
just reflects the upload time.
- Skip the date-write block entirely if photo.taken_at_source == 'manual'
so a rescan can't clobber a user correction.
- parse_exif_datetime: handle the all-zero placeholder some cameras
emit, accept tz-aware variants (%z), normalize to naive UTC.
Frontend: new 'PATH' badge in TakenAtEditor with a tooltip explaining
the date came from filename / folder rather than real EXIF.
Backfill: new backfill_taken_at celery task and
POST /api/v1/library/maintenance/backfill-taken-at endpoint that
re-enqueues extract_metadata for every non-manual photo. ~21k tasks
finish in ~15 min on the existing worker-light concurrency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix NULLed parent_id only for folders within the
SourceRoot being deleted, but folder rows under a *different*
SourceRoot whose path nests inside this one (e.g. 'Leóns 1st Year' at
.../Taco and Muli - 2024 onward/Leóns 1st Year) still pointed into
our delete set. folders_parent_id_fkey kept tripping. Widen the UPDATE
to NULL parent_id for any folder whose parent_id is in folder_ids,
regardless of source_root_id.
UI: trash button on a Nextcloud library now swaps to a spinning
Loader2 while the delete is in flight (only the row being deleted —
others stay as trash icons but disabled). Title updates to flag
that a cascade through every photo + folder can take a few seconds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PATCH /api/v1/photos/{id} returned 500 with
'can't subtract offset-naive and offset-aware datetimes' when the
frontend sent a tz-aware taken_at value (e.g. 2026-05-09T00:12+02:00).
The photos.taken_at column is timestamp without time zone, so asyncpg
refuses to bind a tz-aware datetime.
The frontend's datetime-local input is supposed to be naive but real-
world locales / browsers / paste flows occasionally include offsets.
Normalize on the server: if tzinfo is present, convert to UTC and drop
the tzinfo so both shapes round-trip cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folders have a self-referential parent_id FK with no ON DELETE rule.
A flat DELETE of the whole subtree trips folders_parent_id_fkey because
postgres checks the constraint per-row regardless of insertion / list
order. Hard-removing 'Taco and Muli - 2024 onward' (35-folder subtree)
returned 500 with ForeignKeyViolationError every attempt.
Fix: UPDATE folders SET parent_id = NULL WHERE id IN (folder_ids) before
the DELETE so the chain is broken cleanly. Same pattern used in
prune_missing_photos for the same constraint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes for the Nextcloud library lifecycle.
1. DELETE /api/v1/nextcloud/source-roots/{id} now actually deletes
the SourceRoot, every Folder under it, and every Photo in those
folders (Nextcloud files untouched). Was a soft-deactivate
(is_active=false) that left the rows around forever, so re-adding
the same path resurrected ghosts and prune-missing reported zero.
Returns {deleted_photos, deleted_folders}; the Settings UI toasts
the count and invalidates photos/folders/stats so cached lists
don't show ghosts. photo_tags and heap_photos already cascade via
ON DELETE CASCADE; FolderShare uses a stringly-typed folder_id
with no FK so cleaned up explicitly.
2. The watcher (watch_folders task) was getting killed every five
minutes by the global task_soft_time_limit=300 in app/tasks/celery.py
despite passing soft_time_limit=None on the decorator (None falls
back to the worker default in this Celery version). Override with
soft_time_limit=0, time_limit=0 (= unlimited) so the watch loop
actually stays alive. The 'Soft time limit (300s) exceeded' /
'Worker exited prematurely' lines should stop in worker-watcher
logs.
3. Added discard_missing_photos() in services/cleanup.py — a soft
variant of prune_missing_photos that walks every present source
root, checks os.path.exists for each non-discarded Photo, and
flips is_discarded=true on the missing ones (UPDATE not DELETE).
Wired as discard_missing_photos_beat in tasks/scan.py and
scheduled every 30 min via celery beat. Beat runs in-process on
worker-watcher (--beat flag in compose) — there's only ever one
watcher and we don't need a separate container.
Hard delete remains manual via prune-missing for users who want to
review before committing. The beat catch-up only soft-discards (file
gone -> mule-image trash, restorable).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LibRaw (rawpy 0.26.1, libraw 0.22.0) rejects Apple ProRAW Linear DNG with
'Unsupported file format or not RAW file'. These files aren't Bayer-pattern
RAW — they're TIFF containers holding an already-developed RGB image, so
PIL opens them directly. iPhone Linear DNG also has no embedded preview
exiftool can extract, so the existing fallback chain ran out of options.
Added PIL Image.open(src_path) as the last fallback in both code paths
(_generate_proxy_webp for /photos/{id}/proxy, and tasks.thumbs.process_raw_image
for thumbnail generation). Covers ~1,300 iPhone DNG files in the library
that were 415-ing on every detail view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without this, a non-admin hitting /api/v1/library/stats would see
every other user's active SourceRoot path in the response (e.g.
muli would see /nextcloud-users/admin/files/Photos). Cross-user
visibility into Nextcloud paths is a small info leak in a multi-user
setup. Admins still get the global list when they pass scope=global.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets each mule-image user (matched via OIDC preferred_username,
overridable in Settings) browse their Nextcloud files/ tree from the
mule-image UI and register subfolders as per-user SourceRoots. Reads
stay direct on the bind-mounted /nextcloud-users path; mutations
(upload, delete, rename, move within NC) dispatch through Nextcloud
WebDAV so oc_filecache, trashbin, comments, and desktop-sync clients
stay coherent.
Backend:
- users.nextcloud_username + nextcloud_app_password_enc (Fernet at rest,
key derived from SECRET_KEY) — alembic 0016
- services/nextcloud_dav.py: minimal WebDAV client (PUT, MKCOL, DELETE,
MOVE) with HTTP Basic auth via the per-user app password
- routers/nextcloud.py: GET /browse, /whoami, GET/POST/DELETE
/source-roots (path-scoped to current_user.nextcloud_username with
realpath traversal guard)
- PATCH /api/v1/auth/me to update nextcloud_username and app password
- OIDC callback defaults nextcloud_username from preferred_username on
first login; backfill on existing users; never overwrites a manual
override
- routers/upload.py: stream upload to NamedTemporaryFile, then PUT to
WebDAV (with MKCOL chain) when destination is NC-rooted; existing
Photo row creation runs unchanged
- routers/discard.py empty-trash: WebDAV DELETE for NC files
- routers/photos.py rename + move: WebDAV MOVE for NC paths;
cross-system move/copy returns a clean error
- routers/folders.py rename + create + permanent-delete: dispatch via
WebDAV when targeting NC-rooted paths
Frontend:
- AuthUser carries nextcloud_username + has_nextcloud_app_password
- services/api.ts: nextcloud + account namespaces
- components/dialogs/NextcloudFolderPicker.tsx: lazy tree browser, name
+ submit -> POST /source-roots
- SettingsDialog: new "Nextcloud library" card with username override +
validate, app-password input, list/remove of NC libraries, and the
picker entry point
docker-compose.yml: NEXTCLOUD_USERS_HOST_PATH bind to /nextcloud-users
on backend + 3 workers; NEXTCLOUD_USERS_ROOT + NEXTCLOUD_BASE_URL env.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds OIDC_LINK_BY_USERNAME as a last-resort linking step after
(issuer, sub) and email both miss. Matches IdP preferred_username
against users.username.
Why: local accounts created before OIDC never collected an email
(no UI for it), so the email fallback cannot relink them. A new
SSO login therefore falls into JIT and creates username-1. On a
single-tenant homelab where the IdP owns the namespace, matching
by username is safe and makes first-time SSO transparent for
pre-existing users. Gated behind a flag so multi-tenant deployments
keep the stricter default.
Adds optional SSO via Authentik (or any OIDC provider) alongside the
existing password flow, and pulls profile images from the provider's
`picture` claim or Gravatar so the sharing UI stops looking anonymous.
Password login stays available as a recovery path; JIT provisioning and
admin-group mapping are env-configurable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shares used to activate instantly on the owner's side with no notice to
the recipient. Introduce a pending/accepted lifecycle so a recipient
gets a bell notification on login and explicitly Accept or Decline
before the shared item lands in their sidebar.
Backend
- Migration 0014 adds `status` + `accepted_at` to heap_shares and
folder_shares; pre-existing rows are backfilled to 'accepted' so
nothing disappears from anyone's current sidebar. One-migration trick:
server_default 'accepted' during add_column, then strip so new inserts
fall through to the Python model default 'pending'.
- New recipient-only endpoints: POST /sharing/{heaps|folders}/{id}/accept
(idempotent) and /decline (hard delete, so re-invites are clean).
- New GET /sharing/pending returning {heaps, folders} of outstanding
invites with target_name + owner_username + permission.
- list_shared_{heaps,folders} now filter to status='accepted' and carry
share_id so the recipient can Leave without a second lookup.
- ShareResponse exposes status so the owner sees pending invites.
Frontend
- NotificationBell lives in the LeftSidebar user row: a Popover
triggered by Bell with a count badge. Each row shows owner avatar,
"{owner} shared {heap|folder} {name}" with a permission subtitle,
and Accept / Decline inline. Polls /sharing/pending every 60s.
- Shared Avatar helper extracted to sharing/Avatar.tsx — used by
ShareDialog, NotificationBell, and the sidebar shared rows so one
user's identity colour is stable everywhere.
- Sidebar shared-row polish: owner avatar bubble + Eye/Pencil
permission icon (was uppercase pill). Right-click opens a context
menu with Open / Leave; Leave calls the existing recipient-revoke
DELETE and invalidates the shared-{heaps,folders} query.
- ShareDialog shows an amber "Invited" pill next to pending recipients.
- New shadcn context-menu primitive (radix dep already installed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Memories view now supports the same keyboard shortcuts, heap membership,
and optimistic cache updates as the Timeline. Arrow/Ctrl+A/Escape nav is
extracted into a shared useGridKeyNav hook so both views stay in lockstep.
Duplicates view is virtualised with @tanstack/react-virtual and has
stabilised PhotoThumbnail props so React.memo actually elides work when
scrolling or toggling selection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the free-text username input with a Select populated from a new
/sharing/users endpoint. Users already on the target's share list are
filtered out, and the trigger surfaces loading / empty states. Matches
the existing permission model since sharing only ever required knowing
a username.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drops face recognition, OCR, object detection, and semantic embeddings.
The sole remaining vision task is a CLIP-based binary classifier
(photography vs other); photos in "other" get needs_review=true so
screenshots, documents, memes and scans can be triaged from a new
filter pill in the UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds Redis-backed feature flags for vision stages with admin UI toggles
and manual backfill trigger, photo upload and download routers with
frontend upload modal, and rawpy-based RAW decoding with JPEG fallback
for misnamed DNGs. Fixes pgvector serialization, is_trashed filter, and
naive-datetime bind in incremental duplicate regrouping; bumps Celery
time limits on regroup tasks beyond the 5-minute default.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sharing:
- New HeapShare and FolderShare models with read/write permissions
- Sharing API router (CRUD for heap and folder shares)
- Heap endpoints accept shared access (photo_ids, add/remove with write)
- Photo list drops user_id filter in shared context, adds owner_username
- Media serving (thumb/original/proxy) falls back to share check on 404
- ShareDialog component for managing shares from kebab menus
- HeapsPanel shows "Shared with me" section for shared heaps
- LeftSidebar shows "Shared with me" section for shared folders
- Owner badge on PhotoThumbnail for photos from other users
Auth:
- Access token default bumped to 1 year, refresh to 10 years
- Refresh token persisted in localStorage (survives page reload)
- Timer-based refresh replaced with 401 axios interceptor
Vision pipeline fixes:
- Bootstrap sets Redis ready key even on partial export failure
- Export functions run conditionally (only for actually missing models)
- _load_thumb handles multi-user path (/data/thumbs/{user_id}/{photo_id}/)
- can_access_photo_via_share uses single subquery instead of N+1 loop
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses 16 robustness, transparency, and performance issues across
the Celery media processing pipeline:
Critical:
- Singleton DB engine in vision tasks (was leaking one per task call)
- acks_late + task_reject_on_worker_lost so crashed workers don't lose tasks
- Global soft/hard time limits (5/10 min) to prevent hung worker slots
- Thumbnail copy-before-resize (in-place mutation degraded larger sizes)
- backfill_vision now checks each task type independently (OCR, faces, etc.)
- Parameterized LIMIT in backfill_vision (was f-string SQL injection)
High:
- try/except + retry(max=3) on all vision inference tasks
- extract_metadata writes processing_error on exiftool failure
- PIL Image handles closed in _load_thumb/_load_original
- Scan progress Redis keys auto-expire after 1 hour
- Watcher lock renewal is wall-clock based (30s) not event-count based
- worker_process_init signal warms up vision models on startup
Medium:
- Explicit task_routes for every task name (wildcards never matched)
- app.services.metadata added to Celery include list
- POST /maintenance/recover-stuck endpoint for photos stuck in processing
- Docker healthchecks for worker-light, worker-vision, and Redis
- Task ID in vision log lines for distributed tracing
- Bare except:pass narrowed to specific exceptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add active source root directories to the library stats endpoint and
display them in the settings page. Hardcode container PHOTO_DIRS to
/photos since the volume mount handles host path mapping. Add .env to
.gitignore to prevent committing secrets.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Every user — including the initial admin — now gets their own
subdirectory under PHOTO_DIRS (e.g. /photos/admin, /photos/bob).
No one's source root points to the mount root itself, eliminating
cross-user photo overlap entirely.
- Setup endpoint: admin gets /photos/{username} like everyone else
- Migration: default admin media_path set to /photos/admin
- Remove scan directory pruning (no longer needed)
- Fix thumbnail retry URL: use & separator when token query param
already present (was producing ?token=...?retry=N)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- detect_objects, classify_content, recluster_faces now look up the
photo's user_id and set it on created Tag rows — fixes tags being
invisible to the owning user due to NULL user_id
- Initial admin setup creates source root at the mount root (/photos)
instead of a subdirectory, since the admin owns the entire library
- Revert to OpenCLIP ViT-B/32 (512-d) as default embedder — SigLIP
requires transformers version alignment not yet available in the
Docker image. SigLIP2 code remains for future enablement.
- Add transformers to requirements for future SigLIP support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move watch_folders to dedicated 'watcher' queue with its own
single-concurrency container so it never blocks scan/thumbnail slots
- Add get_current_user_media dependency that accepts ?token= query
param for <img src> / <video src> media endpoints (thumb, original,
proxy) — fixes 401 on thumbnails
- Append JWT token to all media URLs in the frontend
- Add missing 'memories' case in sidebar navigation switch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Re-enable the watchfiles-based folder watcher with a Redis lock to
prevent multiple instances from stacking up across restarts. The
watcher is now automatically dispatched on startup when scanner.watch
is true (default), and only one instance runs at a time.
- Redis lock (SETNX + TTL renewal) ensures single-instance execution
- Graceful exit if another watcher holds the lock
- New POST /maintenance/start-watcher endpoint for manual control
- Fix: use settings.scanner/vision properties instead of mulita_config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a Memories view that surfaces photos taken on the current date in
previous years (like Google Photos / Immich). Only uses EXIF-sourced
dates to avoid false matches from filesystem timestamps.
- Backend: GET /api/v1/photos/memories returns groups by year, up to
12 photos each, filtered to non-discarded/non-hidden EXIF dates
- Frontend: MemoriesView with year-grouped thumbnail grid
- Sidebar: new "Memories" nav item with clock icon
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduce username/password authentication with admin and user roles.
Each user gets their own media directory under /photos/{username}/ with
isolated photos, folders, heaps, and tags. Admins manage users and
observe the full library from a dedicated Settings page.
Backend:
- User model with bcrypt passwords and JWT access/refresh tokens
- Auth router (login, refresh, setup, change-password, status)
- Admin router (user CRUD with last-admin protection)
- user_id FK added to photos, folders, source_roots, heaps, tags
- All data routers scoped by authenticated user
- Scanner inherits user_id from source root owner
- Thumbnails stored under user-prefixed paths for isolation
- Library endpoints accept ?scope=global for admin cross-user view
- Alembic migration 0009 with data migration for existing installs
- Defensive bootstrap.py handles fresh vs existing DB startup
Frontend:
- AuthContext with token lifecycle, auto-refresh, login/logout
- Login page, first-run setup page, auth gate in App.tsx
- Bearer token interceptor on all API requests
- User identity + logout in left sidebar
- Admin-only Settings page with Library Management and Users tabs
- UserManagement panel (add, edit role, reset password, deactivate)
- Settings shows global stats across all users for admin
- Filter bar, right sidebar, keyboard hints hidden on settings page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Renders a thin top-of-cell banner on each photo while a text search
is active, labelling which metadata field matched (filename, title,
note, tag, or EXIF key name) and showing a short excerpt with the
exact matched substring highlighted in amber. Extends the list
endpoint's search to also match photos whose tag names contain the
query so tags show up alongside filename/title/notes/EXIF hits.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lets operators fix corrupted capture dates at scale. Adds an editable
Date Taken field with a folder/filename-derived suggestion hint, a bulk
Date Taken section in the multi-select sidebar that either applies one
date to the whole selection or infers a per-photo date from each path,
a warning badge on thumbnails whose stored date disagrees with the
path, and a "Date issues" filter pill so suspicious photos can be
surfaced and fixed as a group. Edits are written back to EXIF on disk
so rescans don't clobber the fix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a per-folder "hide from views" toggle so noisy subtrees
(screenshots, WhatsApp dumps, work archives) can be excluded from
cross-cutting views without losing indexing. Photos under a hidden
folder are still scanned, thumbnailed, embedded, OCR'd, face-
extracted — they just stop appearing in All Photos, Rated, Colors,
Map, Tags, People, Search, Duplicates, and the sidebar counts.
Navigating directly into the folder still shows every photo.
Schema (migration 0007_folder_hidden):
- folders.is_hidden user-set toggle, default false
- photos.is_hidden denormalized effective flag (true iff any
ancestor folder is hidden), indexed so cross-
cutting queries stay on the existing planner
paths
The denorm is maintained by two paths:
- The scanner walks the ancestry chain on insert, with a per-scan
memoized cache so each folder is resolved once per scan.
- POST /api/v1/folders/{id}/hide flips folders.is_hidden and runs a
WITH RECURSIVE CTE to recompute every folder's effective state in
one query, then bulk-updates photos WHERE IS DISTINCT FROM. Runs
in ~10 ms on a 13k-photo library.
Filters added (cross-cutting queries):
- /library/stats — every sidebar badge via a shared `visible` filter
- /photos (list) — only when neither folder_id nor heap_id is set;
folder browse and heap browse always show everything
- /photos/map
- /library/duplicates/groups
- /folders/tree photo_count subquery
- /tags count_subq (drives Tags + People sidebar counts)
- services/duplicates.regroup_duplicates (so hidden dupes never
contaminate the Duplicates view)
- services/search.hybrid_search — both semantic (pgvector) and FTS
legs join photos so rankings don't include hidden results
Intentionally NOT filtered:
- /photos?folder_id=X and /photos?heap_id=X (user-intentional browse)
- /library/maintenance/pipeline-stats (tracks real worker state)
- cleanup service (disk-level ops, not views)
Frontend:
- sourceFolders.setHidden(id, hidden) API client method
- FolderTreeNode.is_hidden carried through the tree into TreeItem
- LeftSidebar kebab menu: "Hide from views" / "Show in views" with a
mutation that invalidates folders, photos, stats, and tags caches
- Hidden folder rows swap the Folder icon for EyeOff and render the
label italic/muted so the state is visible at a glance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three overlapping fixes so the ingestion pipeline actually runs and the
user can see what it's doing:
Pipeline recovery
- app/database.py: use NullPool when MULITA_CELERY_WORKER=1 so each
Celery task opens a fresh asyncpg connection on its own event loop.
Fixes "another operation in progress" and "Future attached to a
different loop" errors that were dropping ~every thumbnail +
extract_metadata task on the floor.
- app/tasks/thumbs.py: initialize photo=None before the try and rollback
on error so a transport failure in the initial SELECT doesn't raise
UnboundLocalError in the except block and leak rows stuck in 'pending'.
- app/services/vision/bootstrap_models.py: on missing model files,
invoke export_models automatically instead of just warning. First
boot of a fresh install now self-heals.
- app/services/vision/export_models.py: shutil.move instead of
Path.rename so the YOLO export survives the /app → /data/models
cross-volume hop.
- requirements.txt: add ultralytics so export works in a stock image.
Worker topology
- docker-compose.yml: replace the single worker with worker-light
(default/high/low queues, c=2, IO-bound) and worker-vision (vision
queue, c=5, OMP_NUM_THREADS=1 to avoid oversubscription on 6 cores).
Vision is pinned to ≤5 parallel inferences so ONNX doesn't each
spawn an all-cores intra-op pool.
- .env / .env.example: CELERYD_CONCURRENCY replaced with
CELERY_LIGHT_CONCURRENCY + CELERY_VISION_CONCURRENCY.
- Backfill queries in thumbs / scan / vision now ORDER BY taken_at
DESC NULLS LAST so newest photos finish first — the library fills
in top-down in the UI instead of arbitrary insertion order.
Settings visibility
- routers/library.py: new GET /maintenance/pipeline-stats returning
done/total per stage (thumbnails, exif, gps, phash, embeddings,
tags, ocr, faces, face clusters, duplicate groups). Worker-status
now also reports the `vision` queue depth, which was missing.
- services/api.ts: PipelineStats / PipelineStage / ScanStatus types
and the matching client call.
- components/dialogs/SettingsDialog.tsx:
- new Pipeline Progress card with one progress bar per stage
- inline scan banner (processed/total/current folder) inside the
Library section while a scan is running
- Tasks/min throughput computed by diffing worker processed counters
between polls
- Workers section calls out the vision queue and documents the
CELERY_LIGHT/VISION_CONCURRENCY + docker compose up -d scale path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace Timeline-based grouped views (tags, colors, rated) with
dedicated card-grid components that drill into Timeline detail views
on click/Enter. Adds shared useCardGridNav hook for arrow-key
navigation across all four card grids (tags, colors, rated, people).
- TagsView, ColorsView, RatedView: card grid → inline Timeline detail
- PeopleView: migrated to same pattern (Timeline replaces custom grid)
- Tags endpoint: fall back to first associated photo for representative
- Filter store: add ratingMax for exact rating filtering in RatedView
- Timeline: remove tag/rating/color grouping; skip date headers when
groupBy != 'date' so detail views render flat grids
- SettingsDialog: bump z-index above Leaflet map layers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wire the full embedding flow:
- Rewrite Embedding model to use pgvector Vector(512) with HNSW index
- Add embed_photo, vision_fanout, backfill_vision Celery tasks on
dedicated `vision` queue
- Hook vision_fanout into generate_thumbnails completion
- Add POST /api/v1/photos/search with hybrid RRF ranking (semantic-only
for now; FTS leg added in PR5)
- Stub ocr_photo, detect_objects, extract_faces tasks for later PRs
Migration 0003 drops/recreates the embeddings table (was never populated).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unify object detections, scene labels, and face clusters with user tags
via new columns on the existing Tag model:
- kind (user|object|scene|face_cluster), source, representative_photo_id
- photo_tags gains confidence, bbox (JSONB), source per-association
- Uniqueness moves from (name) to (name, kind) so ML labels coexist
with user tags without collision
Add Alembic migration 0002 with defensive IF NOT EXISTS guards.
Update tags router: kind filter on GET, merge endpoint for combining
auto-detected clusters/objects, include kind/source in list response.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a new Map sidebar entry that plots photos by their EXIF GPS
coordinates on a clustered Leaflet map. While wiring this up, the
metadata extractor was reading unprefixed GPS keys that never exist
in `exiftool -G -j` output AND assumed coordinates were already
floats — every photo silently lost its GPS. The new extract_gps
helper handles Composite/EXIF group prefixes and parses DMS strings,
and lat/lon are stored as first-class indexed columns so the map
can query them without parsing exif_json on every request.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a `colored` field to /library/stats counting non-discarded
photos with a color_label set, mirroring how `rated` is exposed.
The Colors sidebar entry now shows the live count and refreshes
through the existing LIBRARY_STATS_QUERY_KEY invalidations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Duplicates section was useless: SHA-256-only detection only caught
byte-identical files, not the actual duplicates a real library
accumulates (re-encoded JPEGs, screenshots, resized exports), and the
view was a flat date-sorted list with no grouping or actions. This
replaces the whole flow.
Detection
- New phash + duplicate_group_id columns on Photo, added via an
idempotent ALTER TABLE pass in init_db (the project has no Alembic).
- Thumbs worker computes a 64-bit pHash from the original-resolution
decoded frame just before the destructive thumbnail loop. Falls back
silently — phash is nice-to-have, not a blocker for thumbnails.
- backfill_phashes Celery task fills in phashes for photos that
predated the column, reading the existing thumb_large rather than
re-decoding the original.
- regroup_duplicates service runs union-find over Hamming distance
(threshold 6), persists duplicate_group_id, and maintains is_duplicate
as derived state so existing badges/counts keep working. Chained
after scan_all_source_roots with a 60s countdown.
API
- GET /library/duplicates/groups returns all groups with members,
bucketed in Python from one query. Each group has a reason ("exact"
iff every member shares a SHA-256, "similar" otherwise).
- POST /library/maintenance/{regroup-duplicates,backfill-phashes}.
Frontend
- New DuplicatesView (sectioned grid, one section per cluster) replaces
the timeline when the user is in the duplicates section. Each section
shows a "Keep best, discard N" button that picks the highest-pixel
copy and reuses the existing undoable bulk-discard so Cmd+Z works.
- Manual best override: hover any non-best thumbnail and click "Keep
this" (Crown icon, top-right) to override the auto-pick. The header
annotates "(manual)" so it's obvious which copy will be kept.
- Keyboard nav within the duplicates view walks the flat member list,
with ↑/↓ jumping by the measured column count and scrollIntoView on
every move. Timeline's keyboard handler now early-returns in the
duplicates section so the two don't fight.
- BEST pill / Keep-this button live at top-right with a ring outline so
they don't collide visually with the cyan selection ring around a
selected cell. Dimensions chip moved to bottom-left to free both
right corners for the keep affordances.
- New "Duplicates" section in SettingsDialog: shows group/member counts
and exposes both backfill + re-detect actions, sharing a query cache
with DuplicatesView via DUPLICATE_GROUPS_QUERY_KEY.
- PhotoInfoPanel "Basic Info" section now shows the photo's full file
path in monospace below the size/dimensions/date grid.
- New imagehash==4.3.1 dep in requirements.txt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Parallelize the six celery inspect.*() calls in /library/maintenance/
worker-status via asyncio.gather + to_thread, and drop per-call
timeout from 1.0s to 0.5s. Endpoint goes from ~6.1s to ~0.54s — it
was the sole bottleneck on opening the Settings dialog.
- SettingsDialog now fetches through React Query with enabled:isOpen,
so reopening shows cached data instantly while a background refetch
updates. Worker polling moved to refetchInterval. Loading spinners
only show when there's no cached data yet, so background refetches
don't keep them spinning.
- Move the Settings entry point from the TopBar to a pinned row at the
bottom of the LeftSidebar so it sits alongside the other library
controls. TopBar no longer takes onOpenSettings.
- Remove the "Scan all folders" bottom action from LeftSidebar — the
same control already lives in Settings → Library → Re-scan.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds /api/v1/library/maintenance/{missing-stats,prune-missing} backed
by a new cleanup helper that deletes Photo rows whose files no longer
exist on disk under a *mounted* source root. Skips photos under
unmounted roots so a temporarily-disconnected drive doesn't get
silently nuked.
Settings panel surfaces the orphan count with a destructive Prune
button, plus a "Kick pending" action that re-queues photos stuck in
processing_status='pending' (typically left behind when the scanner
created the row but the worker never picked up the thumbnail task).
Common trigger: PHOTO_DIRS in .env was repointed at a different
library root, leaving every old row dangling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds /api/v1/library/maintenance/worker-status (Celery inspect + queue
depths + recent failed photos) and a Workers section in the Settings
dialog so users can debug stuck queues and task failures without
tailing container logs. Auto-polls every 5s while open.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- photos.py: stop crashing in FileResponse when a thumb hasn't been
generated; return a clean 404 with Retry-After so the frontend can
back off.
- thumbs.py: fix process_video_thumbnail (overwrite_output, robust
duration probe across stream/format, eager frame load + temp cleanup)
so videos stop ending up as the gray placeholder.
- library.py: new /maintenance/* endpoints — thumbnail-stats,
regenerate-thumbnails (with media_type / only_failed filters), and a
manual data-integrity cleanup trigger.
- Frontend Settings panel (gear in TopBar) surfacing those endpoints
plus a re-scan button and live thumbnail status counts.
- PhotoThumbnail: stretch the auto-retry schedule for slow RAW jobs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The left sidebar can now create, rename, and delete folders. Each
operation is mirrored to disk through the backend.
Backend (folders router):
- POST /folders { name, parent_id } — create a sub-folder under an
existing Folder row, mkdir on disk, insert the row, return it. Names
are validated (no separators, no traversal).
- PATCH /folders/{id} extended — still does the display-only rename for
SourceRoot ids, but for Folder ids it now actually moves the directory
on disk and rewrites every descendant Folder.path + Photo.filepath
that lived under the old prefix in a single transaction. Refuses to
rename the source-root mount itself.
- DELETE /folders/{id}?mode=discard|permanent —
discard: set is_discarded on every photo whose filepath lives under
this folder. The folder, descendants, and on-disk dir are
left intact. Recoverable from the discard pile.
permanent: unlink each file, remove rows, rmtree the directory.
- Refuses to delete the source-root mount in either mode.
Frontend:
- New DeleteFolderDialog: two-card mode picker (Move to discard pile /
Permanently delete) with destructive accent on the latter. Esc and
backdrop click cancel.
- LeftSidebar: hover-revealed kebab menu on every folder row with
New sub-folder, Rename, and Delete folder… Inline create input
appears below the parent row when "New sub-folder" is picked.
All mutations invalidate ['folders'], ['photos'], and the library
stats query so the sidebar counts stay live.
- api.ts: sourceFolders.create + sourceFolders.delete wrappers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FilterPill: drop the inline value text from the active state. Pills
now stay the same width whether or not a filter is set; the popover
is the canonical place to read the value, and the title attribute
surfaces it on hover.
- TopBar: remove the search input — search lives in the filter bar now.
- FilterBar: add a search input on the left, with the pill cluster
centered between it and a flex-shrink-0 Clear-all on the right.
- LeftSidebar / HeapsPanel: count badges use a fixed-width slot
(h-5 min-w-[24px], tabular-nums) so counts line up in the same
visual column across rows. Empty rows reserve the slot.
- LeftSidebar: pull section counts (All Photos, Rated, Duplicates,
Discarded) from a new useLibraryStatsQuery hook backed by the
expanded /library/stats endpoint. Tags count was already wired.
- backend/library: stats endpoint returns per-section counts that
match the filter the sidebar applies on click.
- Stats invalidation hooked into the standard photo-mutation paths.
- RightSidebar header: h-12 to match TopBar height.
- Timeline sticky date overlay: only show once the natural in-grid
header has scrolled OUT of the viewport. Avoids the duplicate-label
flash when both labels would be visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The heap row used to fan out three small icon buttons (set active, convert
to folder, delete) on hover, which crowded the row and didn't leave room
for new actions. Collapse the destructive / occasional ones into a kebab
menu and add the missing operations.
- Right-aligned action cluster: active indicator → count badge → target
toggle (when not active) → kebab menu, all flex-shrink-0 so the name
truncates first.
- Kebab menu items: Rename, Duplicate, Move to folder…, Delete. Outside
click and Escape close the popover; the trigger has aria-haspopup +
aria-expanded. Delete still confirms via window.confirm.
- Inline rename: double-click a heap row OR pick Rename from the menu
to edit the name in place. Enter commits, Escape cancels. Mirrors the
folder rename pattern in LeftSidebar.
- backend: new POST /heaps/{id}/duplicate creates a copy with the same
membership ("{name} (copy)") via INSERT...SELECT on heap_photos.
Never marks the new heap as active so duplicating doesn't quietly
steal the user's T-key destination.
- api.ts: heaps.duplicate wrapper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The bulk action panel previously covered rating, color, flag, and pick
but had no way to apply tags across a multi-photo selection — the only
path was to tag photos one at a time via the single-photo PhotoInfoPanel.
Add it.
- backend: extend the existing /photos/bulk action endpoint with
add_tags and remove_tags actions. add_tags is idempotent (computes
the new (photo_id, tag_id) pair set against existing rows and inserts
only the missing ones); remove_tags is a single DELETE WHERE IN.
- api.ts: bulkAddTags / bulkRemoveTags wrappers.
- RightSidebar: new BulkTagsEditor below the bulk flag row. Filters /
searches the existing tag list, lets the user click any chip to apply
it to the whole selection or X to remove it. Typing a name with no
exact match shows a "Create and apply" button that creates the tag
via tagsApi.create and immediately attaches it to every selected
photo. All three mutations invalidate both the photo and tag caches
so the FilterBar tag count + sidebar Tags section stay fresh.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- backend/photos: collapse the per-tag subquery loop in the tag filter
into a single GROUP BY ... HAVING COUNT(DISTINCT) = N subquery so the
cost is independent of how many tags the user is filtering on.
- useFilterUrlSync: type the parseUrl return value as
Partial<FilterState> & { currentSection?: string } so the section field
doesn't need an (out as any) cast.
- Timeline sticky header: bump opacity, padding, and border so it reads
more clearly against the underlying grid.
- FilterPill clear: convert the nested <button> (invalid HTML — buttons
cannot nest) to a span with role=button + keyboard handler, with a
larger hit area.
- RightSidebar: add aria-label to the close-X buttons so screen readers
announce them.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- backend/photos: whitelist sortable columns instead of getattr(Photo, sort).
Previously any client-supplied string was passed to SQLAlchemy, exposing
every Photo attribute (filepath, file_hash, etc.) as a sort target.
- App: move the auto-show-right-sidebar logic out of the render body and
into a useEffect. The previous version called setState during render,
causing extra re-render passes the audit caught.
- types/photo: add added_at and tighten folder_id from optional to nullable.
Drops a (photo as any).added_at cast in Timeline.
- constants/colorLabels: extract a single COLOR_LABEL_OPTIONS used by
FilterBar, RightSidebar, and PhotoInfoPanel. filterStore re-exports the
ColorLabel type so existing imports keep working.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>