Commit Graph

63 Commits

Author SHA1 Message Date
Claudio
09a00f7419 feat(nextcloud): hard-delete SourceRoot + reliable delete sync
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>
2026-05-10 22:19:58 +02:00
Claudio
758fda619e fix(raw): PIL fallback for iPhone Apple ProRAW / Linear DNG
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>
2026-05-10 21:20:53 +02:00
Claudio
e4127f1e04 fix(library): scope source_dirs to current user in /stats
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>
2026-04-26 09:46:54 +02:00
Claudio
bc0bb44c05 feat(nextcloud): per-user Nextcloud library integration
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>
2026-04-26 01:06:37 +02:00
root
80dd9d0a8b feat(auth): OIDC link by preferred_username (opt-in)
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.
2026-04-22 22:24:13 +02:00
e8e1adcf37 feat(auth): Authentik OIDC sign-in + Gravatar avatars
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>
2026-04-22 21:06:32 +02:00
319be20389 feat(sharing): pending-state invites, notification bell, sidebar polish
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>
2026-04-21 23:17:53 +02:00
root
d72a218b46 feat: full shortcut parity + perf fixes across memories and duplicates
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>
2026-04-15 11:51:13 +02:00
967cf23b82 feat(sharing): user picker in share dialog
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>
2026-04-15 10:34:09 +02:00
574d71371f refactor: strip AI pipeline to binary photo/other classifier
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>
2026-04-14 22:27:17 +02:00
root
5c531f11da feat: runtime feature flags, upload/download, RAW decoding
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>
2026-04-14 21:31:52 +02:00
root
edd569d095 feat: share heaps and folders with other users, fix auth and vision pipeline
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>
2026-04-13 10:59:07 +02:00
root
f090a809a9 fix: harden pipeline — retries, acks_late, time limits, session safety
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>
2026-04-13 08:57:10 +02:00
root
e974ffbfd2 feat: show source directories in library stats and settings UI
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>
2026-04-13 08:09:08 +02:00
b7aa2aed3d fix: all users get subfolders, nobody owns the mount root
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>
2026-04-13 00:18:42 +02:00
fbeefb24a0 fix: vision tasks inherit user_id, admin owns mount root
- 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>
2026-04-13 00:01:28 +02:00
35d87a2749 fix: dedicate watcher to own worker, fix media auth + memories nav
- 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>
2026-04-12 23:34:40 +02:00
d693569f59 feat: auto-start file watcher with Redis lock for live import
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>
2026-04-12 22:24:38 +02:00
fc8dd370c2 feat: "On this day" memories — photos from previous years
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>
2026-04-12 22:18:28 +02:00
348e9c3585 feat: multi-user auth with per-user media isolation
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>
2026-04-12 21:47:43 +02:00
38d975e354 feat: highlight matched metadata on thumbnails during search
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>
2026-04-11 21:38:27 +02:00
30d03d8d4d feat: editable taken_at + folder-based date repair and filter
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>
2026-04-11 11:48:55 +02:00
root
339e1be510 feat: hide-from-views flag on folders
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>
2026-04-11 10:55:25 +02:00
root
07b1e5e02a feat: split celery workers, fix asyncpg-in-fork, add pipeline progress UI
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>
2026-04-11 10:06:45 +02:00
4bc6dc1dc8 feat: refactor grouping views into card-grid browse pattern
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>
2026-04-10 15:48:50 +02:00
649437dc85 feat: add embeddings pipeline and semantic search endpoint
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>
2026-04-10 09:07:32 +02:00
b1c2bdf7f0 feat: extend Tag model for unified ML tagging
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>
2026-04-10 09:02:32 +02:00
7cf546af7a feat: map view with GPS extraction fix
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>
2026-04-09 23:44:29 +02:00
1c6383e5a0 feat: colored count badge on Colors sidebar entry
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>
2026-04-09 20:43:19 +02:00
733c16bf82 feat: perceptual-hash duplicate detection + grouped picker view
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>
2026-04-09 17:19:08 +02:00
e51b93d59e perf: speed up settings dialog + relocate settings entry point
- 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>
2026-04-09 16:31:52 +02:00
root
697343646a feat: prune orphaned photo rows + retry-pending action
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>
2026-04-09 10:47:06 +02:00
root
42250aa16e feat: worker diagnostics in settings panel
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>
2026-04-09 10:11:33 +02:00
9ed577f40c feat: settings panel + thumbnail pipeline fixes
- 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>
2026-04-09 09:24:08 +02:00
f05ae77ef0 feat: folder CRUD with discard-or-delete dialog
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>
2026-04-08 20:51:23 +02:00
bd904aca36 fix: assorted UI polish from review pass
- 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>
2026-04-08 20:45:30 +02:00
696477eefd feat: heap row kebab menu with rename + duplicate
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>
2026-04-08 20:36:00 +02:00
a5b4054a71 feat: bulk tag add/remove on multi-select right sidebar
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>
2026-04-08 20:25:18 +02:00
a55839d9a2 fix: more audit findings — perf, types, and a11y polish
- 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>
2026-04-08 20:22:23 +02:00
749e836617 fix: high-severity findings from code audit
- 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>
2026-04-08 20:18:19 +02:00
b870084be0 feat: per-photo permanent delete + discarded thumbnail treatment
Discarded photos now look discarded in the grid (50% opacity + grayscale)
with a red trash badge in the corner instead of a bare icon. The discard
action bar gains a "Delete N" button that permanently deletes only the
current selection, complementing the existing "Empty discard pile".

Backend: new DELETE /discard endpoint accepting {photo_ids: [...]} that
permanently removes only listed photos. Skips ids that aren't in the
discard pile so it can never bypass the soft-delete safety net.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:12:11 +02:00
8413b112ee fix: folder tree counts computed from photos (not stale folder.photo_count)
The sidebar showed Juno=7 and sub=blank because the scanner's
folder.photo_count bookkeeping is broken end-to-end:

  for root, dirs, files in os.walk(folder_path):
      folder = await get_or_create_folder(...)
      ...
      processed_files += 1     # global counter

  # AFTER the loop:
  folder.last_scanned = datetime.utcnow()
  folder.photo_count = processed_files   # only the LAST folder

processed_files is the running total across the whole walk, not
per-folder; and the assignment runs once after the loop, only on
whichever folder os.walk happened to visit last. Result: that folder
gets the grand total, every other folder gets nothing (or stale).

Rather than fix the scanner's bookkeeping (which has leaked into
two production scans already), the tree endpoint now computes
counts on demand from the photos table:

- One GROUP BY per source root: photo.folder_id → COUNT, excluding
  discarded
- Each node starts with its DIRECT count
- A post-order walk accumulates descendants so every node reports
  recursive count — i.e. clicking the row gives you that number of
  photos because the photos query also expands descendants

The stored Folder.photo_count column is now unused by the API. A
future cleanup could drop it from the model entirely.

Verified on the dev DB: Library=7 (4 direct + Juno=2 + sub=1),
Juno=2, sub=1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:11:22 +02:00
6985026106 feat: tag-grouped timeline view
Reworks the Tags sidebar entry from an expandable list of tags into a
single leaf entry. Clicking it switches the timeline grouping mode to
"tag" — every tag becomes a sticky-headered group, with an "Untagged"
group at the bottom for photos with no tags. A photo with N tags
appears in N groups. Existing filters and sort still apply within each
group.

Backend
- list_photos eagerly loads Photo.tags via selectinload to avoid an
  N+1 round-trip.
- Each photo in the list response now carries a `tags: [{id, name,
  color}]` array. The route stops using PhotoListResponse strict
  validation (returns a plain dict with the same shape plus the new
  field) so we don't have to extend the pydantic schema.

Frontend
- Photo TS type gains an optional tags field plus a PhotoTagSummary
  alias.
- filterStore: new groupBy: 'date' | 'tag' field, default 'date',
  with setGroupBy + URL sync via ?group=tag. clearAll resets it.
- usePhotosQuery threads groupBy through filtersToParams (it's
  client-side only but kept in the params for cache key
  consistency).
- LeftSidebar Tags entry is now a leaf node (no children), shows the
  total tag photo count as the badge, and is highlighted when
  groupBy === 'tag'. Click → setGroupBy('tag') without touching
  other filters. Selecting "All Photos" resets groupBy back to
  'date' via clearAll.
- Timeline.buildItems gets a third "tag" branch:
  - Iterates photos × tags into per-tag buckets
  - Photos with no tags go into an "Untagged" bucket
  - Tag groups sorted alphabetically; Untagged pinned to the end
  - Headers + rows pushed in the same shape the date branch uses,
    so the existing sticky-header overlay works for free
- Selection state is by photo id, so a photo appearing in multiple
  groups stays consistently selected/highlighted across instances.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:51:21 +02:00
914eb58ac5 feat: real recursive folder tree in the sidebar
The Folders section in the LeftSidebar previously rendered the flat
list of source roots — actual subdirectories were invisible. Now it
shows the full nested tree, click any node to filter, drop targets
work at every depth.

Backend
- New GET /folders/tree returning a list of root nodes (one per
  active SourceRoot). Each node is { id, name, path, photo_count,
  children: [...] } with children sorted alphabetically at every
  level. Walks Folder rows whose source_root_id matches and whose
  path is at or beneath the source root, then attaches them by
  parent path so partial scans don't break the tree.
- The source root's display label is overlaid on the root folder
  node so the top-level entry reads as "Library" instead of
  "/photos".
- list_photos folder_id filter now does descendant matching: when
  a Folder id is given, it includes the folder itself and every
  Folder whose path is a sep-prefixed descendant. Matches the
  Lightroom mental model: clicking "Library" or any parent folder
  shows everything beneath it. The existing source-root-id branch
  is unchanged.

Frontend
- New types/api.ts FolderTreeNode interface and sourceFolders.tree()
  helper.
- New hooks/useFolderTreeQuery.ts with a 30s staleTime and a
  findFolderInTree() walker for id-based name lookups.
- LeftSidebar drops the flat foldersData list and uses the tree
  query. folderNodeToTreeItem recursively maps backend nodes into
  the existing TreeItem shape; renderTreeItem already knew how to
  recurse into children, so the tree just works at any depth.
  Drop targets, drag-to-move, drag-to-copy, double-click rename,
  and active-state highlighting all carry over to nested folders.
- The renameMutation now also invalidates ['folders', 'tree'] so a
  source-root rename refreshes the tree label immediately.
- ActiveFilterChips switches to the tree query and uses the new
  findFolderInTree walker so the chip label resolves correctly for
  sub-folder filters too — not just top-level source roots.
- The "Scan all folders" button visibility now keys off the tree
  length instead of the flat folders length.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:36:01 +02:00
bb7c2b12d6 feat: heap convert can create a subfolder under the target
The previous /heaps/{id}/convert dropped photos directly into a chosen
source root, which is rarely what you want — Lightroom-style behaviour
is "make a folder named after the collection inside the library".
Now the dialog lets you do that.

Backend
- HeapConvertBody gains an optional subfolder_name field. Path
  separators and dot-segments are rejected. When set, the handler
  joins it onto the resolved parent_dir, mkdir's it if missing, and
  uses the resulting path as the move/copy destination. Otherwise
  the parent_dir itself is used (unchanged behaviour).
- The Folder DB row for the destination is created via the existing
  scanner get_or_create_folder helper so dedupe + path normalization
  stay consistent across the codebase.
- The target source root id is propagated through both the source-
  root and folder branches so the new Folder row is correctly
  parented when subfolder_name is set on a folder target too.

Frontend
- HeapConvertDialog grows a "Subfolder name" input that prefills
  with the heap name when the dialog opens. Trimmed empty value
  drops directly into the parent. A live hint below the input
  shows exactly which path will be created (or that the parent
  will be used).
- api.ts heaps.convert() signature accepts an optional
  subfolder_name field; the dialog sends it via mutationFn.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:31:14 +02:00
bed817d274 feat: heap convert-to-folder + surface exact-duplicate detection
Two related polish items.

1. Heap convert to folder
   Closes a long-standing TODO from spec §6.10.
   - Backend: POST /heaps/{id}/convert with body
       { target_id, mode: 'move'|'copy', delete_heap: bool }
     target_id resolves either as a Folder id or a SourceRoot id (same
     convention as /photos/move). For each member photo, dispatches
     either shutil.move + photo.folder_id update, or shutil.copy2 +
     a new is_duplicate=true Photo row with all metadata copied. Name
     collisions on copy use the same " (copy N)" suffix scheme as
     /photos/copy. The heap row is optionally deleted on success.
     Per-photo failures are collected into the response instead of
     aborting the batch.
   - Frontend: new HeapConvertDialog with a target-folder dropdown
     (currently from sourceFolders.list, sub-folder picking is a
     follow-up), move/copy radio, and a "delete heap" checkbox.
     HeapsPanel rows get a hover FolderOutput button that opens it.
     Toast on success names the verb + count and notes whether the
     heap was deleted; invalidates heaps + photos + folders queries.

2. Surface exact-duplicate detection
   The scanner already sets Photo.is_duplicate=true when a SHA-256
   match is found, but nothing surfaced it. Now:
   - Backend list_photos accepts an optional is_duplicate query
     param so the frontend can filter duplicates-only views.
   - filterStore gains a duplicates: boolean field with setter, URL
     sync (?duplicates=true), filtersToParams entry, and a
     hasActiveFilters check.
   - LeftSidebar gets a new "Duplicates" library node (Copy icon)
     that clearAllFilters() + setDuplicates(true). isItemActive
     follows the filter so the highlight stays in sync after
     external filter changes.
   - PhotoThumbnail renders a small dark badge with the Copy icon
     bottom-right when photo.is_duplicate. Sits next to the existing
     basket / discard badges so the user can spot duplicates at a
     glance.
   - Photo TS type adds is_duplicate.

Perceptual-hash duplicate detection (re-encoded / resized matches)
is intentionally a follow-up — needs an imagehash dep, a phash
column, a backfill job, and similarity-search endpoint with
hamming-distance grouping. This commit only surfaces what the
scanner already finds via byte-level SHA-256 comparison.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:28:18 +02:00
bc1e63095c feat: tags end-to-end (CRUD, photo membership, filter, sidebar UI)
The Tag model and photo_tags join table were already in place; this
fills in the rest — full backend CRUD, per-photo add/remove, list-
endpoint filtering, and a Tags section in the RightSidebar with
autocomplete-create.

Backend
- routers/tags.py rewritten from a 27-line stub:
    GET    /tags                — list with photo counts
    POST   /tags                — create (idempotent on name)
    PATCH  /tags/{id}           — rename / recolor
    DELETE /tags/{id}           — delete (FK cascades photo_tags)
- routers/photos.py:
    POST   /photos/{id}/tags    — add tag ids (idempotent)
    DELETE /photos/{id}/tags/{tag_id} — remove
    GET    /photos/{id}         — now returns a `tags` list alongside
                                  the existing PhotoResponse fields
                                  (fetched via the photo_tags join)
- list_photos applies the existing tag_ids query param: comma-
  separated, AND semantics, one IN-subquery per id since SQLite
  has no native set-contains-all.

Frontend
- New hooks/useTagsQuery.ts.
- services/api.ts: Tag interface, full tags client (list/create/
  update/delete), addToPhoto/removeFromPhoto helpers.
- filterStore: tagIds: string[] field, setTagIds, toggleTagId,
  hasActiveFilters update, filtersToParams sends tag_ids comma list.
- useFilterUrlSync round-trips ?tag_ids=… so tag-filtered views
  are bookmarkable.
- usePhotosQuery threads tagIds through.
- RightSidebar gains a new Tags section using a TagsEditor
  component:
    - shows existing tag chips with X to remove
    - autocomplete input that matches the user's typing against
      existing tag names
    - shows an inline "+ Create '<name>'" affordance when there's
      no exact match
    - Enter creates and attaches in one shot; Esc clears the input
    - existing colour values render as a tinted chip background
- FilterBar gets a Tags group (only rendered when there's at
  least one tag) with toggleable chips per tag.
- ActiveFilterChips shows "Tag: <name>" chips for each active
  tag id, looking up names lazily from the tags query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:20:32 +02:00
63383ecf1c feat: watcher source-root resolution, folder rename, alt-drag copy
Three small phase-11 follow-ups in one commit since they all touch the
same surface area.

1. Watcher source-root resolution
   The watch_folders task previously called scan_folder.delay(parent_dir)
   when files arrived, with no source_root_id. scan_folder would then
   auto-create a fresh SourceRoot for that arbitrary subdir, polluting
   the source_root list. Now the watcher loads (path, id) pairs at
   startup, defines find_source_root_for() that walks the parent chain,
   and dispatches with the resolved id. Events under no known root are
   logged at debug and ignored instead of creating stale rows.

2. Folder rename via UI
   - Backend: PATCH /folders/{id} accepts { name } and updates the
     SourceRoot display label only. The on-disk path is controlled by
     the docker mount and intentionally not editable from the UI.
   - Frontend: double-click a folder row in the LeftSidebar to start
     editing; Enter or blur commits, Esc reverts. New renamingId /
     renameDraft local state and a renameMutation that invalidates
     ['folders']. The click handler ignores clicks while the row is
     in edit mode so it doesn't navigate.
   - api.ts: new sourceFolders.rename(id, name) helper.

3. Bulk copy via Alt-drag onto folder
   - Backend: new POST /photos/copy that mirrors /photos/move but uses
     shutil.copy2 and creates fresh Photo rows with is_duplicate=true.
     Name collisions are resolved by appending " (copy)", " (copy 2)",
     etc., up to 100 tries before erroring. Same target_id resolution
     as /move (folder id or source root id).
   - Frontend: photos.copy(ids, targetId) helper. LeftSidebar's
     handleDrop now takes a `copy` flag derived from e.altKey on the
     drop event; folder targets dispatch copyDropMutation when held,
     moveDropMutation otherwise. The drop-effect cursor flips to
     'copy' on dragover when Alt is pressed so the user gets visual
     confirmation. Discard target ignores the modifier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:58:49 +02:00
320107841b refactor: config-driven libraries; drop folder-add UI
The frontend AddSourceFolderDialog let users register source roots
from inside the app, but with the bootstrap auto-creating one for
the /photos mount on first boot, the dialog was redundant in the
common case and confusing in every other (users had to know which
container path corresponded to their host directory). Going
config-driven matches Plex/Photoprism/Immich and matches the
mental model "the docker mount IS the library".

Frontend
- Deleted components/dialogs/AddSourceFolderDialog.tsx entirely.
- LeftSidebar drops the "+ Add Source Folder" button + bottom-bar
  layout, the addFolderMutation, the dead Plus action button on
  the (no-longer-existing) folders/heaps tree headers, and the
  Plus icon import.
- api.ts: removed sourceFolders.add(), library.browse(), and the
  BrowseChild / BrowseResponse types. The remaining sourceFolders
  surface is read-only (list + manual scan).
- LeftSidebar bottom strip is now just the "Scan all folders"
  button when there's at least one source root.

Backend
- Dropped POST /folders (no consumers) along with FolderCreate /
  FolderResponse pydantic models. The folders router header now
  documents the config-driven approach.
- Dropped GET /library/browse (no consumers). Removed the unused
  os/HTTPException/SourceRoot imports it brought in.
- cleanup_data_integrity now also walks the source roots and logs
  a warning for any whose path is missing on disk. Doesn't auto-
  delete (a missing path could be a temporarily unmounted drive)
  but surfaces enough hint to fix it. Returns the count in the
  summary dict alongside merged-duplicates.

Docs
- README "How libraries are managed" section rewritten to spell
  out that mounts ARE source roots, edit .env + restart, no UI for
  managing source roots. New "Changing or adding libraries"
  section walks through the typical edit-restart loop including
  the optional volume-nuke for a clean slate.
- "Adding more libraries" subsection covers multi-mount via
  edited compose with a note that auto-registration is roadmap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:44:48 +02:00
204d2bf2a8 feat: simplify folder setup — single mount, auto bootstrap, browser dialog
Cleans up the maze of overlapping ways folders entered the app, plus
removes the dead trash plumbing left over from the soft-discard
refactor.

Setup model (now)
- ONE env var: PHOTO_DIRS in .env, set to the host path of your
  library. Compose mounts that at /photos. That's the entire setup.
- On first boot, the backend auto-creates a SourceRoot row named
  "Library" pointing at /photos so the user sees their photos
  immediately without configuring anything.
- Source roots and discard live in the database; mulita.yml only
  carries operational settings (thumbnails, scanner, performance).
- The "Add Source Folder" dialog is now a directory browser
  restricted server-side to /photos and any existing source root —
  the user clicks through actual mounted directories instead of
  typing container paths they can't possibly know.

Backend
- New services/scanner.bootstrap_default_source_root(): if no
  SourceRoot rows exist and /photos is mounted, create one. Wired
  into the lifespan handler before cleanup + initial scan.
- New GET /library/browse?path= returning the immediate child
  directories of `path`, validated to live under one of the allowed
  roots (default mount + every active SourceRoot). Hidden entries
  are filtered. Children are tagged with is_existing_root so the UI
  can show an "Added" badge. Returns parent path for up-nav, or
  null when at the top of the allowed scope.
- scan_all_source_roots now reads from the DB instead of the YAML
  config so DB-managed source roots are honoured by initial scan.
- Dropped the placeholder source_roots block from mulita.yml — the
  paths /photos/main and /photos/iphone never existed and just
  produced startup warnings.
- Dropped TrashSettings, settings.trash, settings.source_roots,
  and the SourceRoot pydantic model from config.py. Soft discard
  has owned this for a while; it was dead code.

Compose
- Single ${PHOTO_DIRS:-./photos}:/photos:rw mount in both backend
  and worker.
- Removed the hardcoded ~/Pictures:/host/Pictures:rw mount — the
  PHOTO_DIRS variable is the single source of truth now.
- Removed the trash_data named volume + mounts (no consumers).
- backend/Dockerfile no longer creates /data/trash; it now creates
  /data/proxies (which the proxy endpoint actually uses).

Frontend
- AddSourceFolderDialog rewritten as a directory tree picker:
  loads /library/browse on open, lets the user navigate up via a
  ChevronUp button or down by clicking subfolders, shows the
  current path inline, and adds whatever directory is currently
  shown. Existing source roots are tagged "Added" so the user
  knows what's already registered. Errors from the backend (e.g.
  trying to navigate outside the allowed scope) surface inline.
- New library.browse() helper + BrowseChild / BrowseResponse types
  in services/api.ts.

Docs
- README Quick Start rewritten around the single PHOTO_DIRS env
  var, with macOS/Linux/Windows examples.
- New "How mounted folders and source folders relate" section that
  spells out the two-layer model (mount = visibility, source root
  = scanning) so the most common confusion is addressed up front.
- Added a "Read-only libraries" subsection that lists exactly which
  endpoints fail under :ro.
- "Configuration" section reframed: source roots are managed by the
  UI/API now, mulita.yml is operational settings only.
- .env file now has examples for the common host paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:27:21 +02:00