335 Commits

Author SHA1 Message Date
b2b6060872 feat(move): "move to folder" for grid selections, folders, and m shortcut
Extends the heap-only "move to folder" action to grid single/bulk
selections, sidebar folders, and an `m` keyboard shortcut — all through
one shared dialog driven by a moveDialog store.

Backend (sidecar):
- Extract the heap move/copy + reindex loop into a reusable movePhotoFiles
  helper plus resolveMoveTarget
- POST /photos/move: move/copy an arbitrary UID list into a folder
- POST /folders/:rel/move: reparent a folder dir (whole subtree) under a
  new parent, guarding against moving into itself/a descendant

Frontend:
- moveDialog store + generalized MoveToFolderDialog (heap | photos | folder
  subjects); mounted once in +layout.svelte. Replaces HeapConvertDialog
- movePhotosToFolder / moveFolder service fns
- Entry points: BulkActionBar button, gridKeyNav `m`, FolderTree kebab,
  heap kebab — all call openMove()

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:10:19 +02:00
5be6fd9047 feat(archive): red-cross flash + instant tile removal on archive/delete
Archive/delete now flash a red cross then drop tiles from the grid
immediately, instead of a green check that lingered until the slow
server-reconcile refetch landed. Keyboard `x` archive previously never
called markRemoved, so tiles only vanished on refetch — that lag is gone.

- Add 'removed' bulk state + removedBulk() helper (red cross overlay)
- gridKeyNav archive/delete: removedBulk -> 500ms flash -> markRemoved,
  clearRemoved once refetch settles; restore stays green check
- BulkActionBar: BulkConfig.removing routes archive/delete through the
  red flash; approve/restore/label/note unchanged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:45:38 +02:00
3e164c48d0 fix(notes): page PhotoPrism server-side so /notes shows every captioned photo
Client-side paging of listPhotosWithNotes stopped early for BasePath users:
the sidecar post-filters each page by BasePath, so a full upstream page can
arrive short, tripping the `length < PAGE` end condition before the library
is exhausted — hiding notes past the first slice.

Add GET /api/sidecar/notes: the sidecar pages /api/v1/photos to completion
(keying the loop off the raw upstream page length), filters to non-empty
Caption under the caller's BasePath, dedupes by UID, and returns the set.
listPhotosWithNotes now calls this single endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:43:56 +02:00
259adb6a41 fix: tile overlay reactivity + facets showing all marked/noted photos
Problem 1 — per-photo progress overlay never rendered on the grid:
- bulkPhotoStates was `$state(new Map())`; a `.get(uid)` read in PhotoTile
  didn't reliably re-run when the entry flipped, so the spinner/check/X
  overlay never appeared. Switch to SvelteMap (svelte/reactivity).

Problem 2 — Notes / Colors / Ratings only showed the newest ~1000 photos:
- All three derived from `listPhotos({ count: 1000 })`, silently hiding
  older marked/noted photos.
- listPhotosWithNotes now pages the whole library.
- Add listPhotosByUids() and resolve the Colors/Ratings marks-pool from the
  complete marked-UID set (from getAllMarks) instead of the newest slice;
  wire it into the TagsBrowserSidebar panel and the tag drill page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:37:30 +02:00
ccf2c6b7c7 fix: bulk label apply, instant archive removal, per-photo state, drop sidebar counts
Issue 1 — colors/labels not applying in bulk:
- sidecar validColors only accepted 4 of the 8 UI swatches, so teal/blue/
  purple/pink returned "invalid color" and rolled back the whole bulk txn.
  Add teal, blue, purple, pink to validColors.
- Add invalidateFacets() and call it on the success path of bulk marks,
  patchTargets, and single-photo edits so the Colors/Ratings/Notes facet
  sections refresh immediately instead of waiting out staleTime.

Issue 2 — archived photos linger in the grid:
- Add a UI-only removedIds set to the bulkAction store; archive/delete/
  restore/keep call markRemoved() on success so tiles vanish instantly,
  cleared once the server-reconcile refetch lands (no cache eviction).

Issue 3 — per-photo progress state:
- Wire startBulk/doneBulk/failBulk into all metadata applies, bulk
  (BulkMetadataSidebar) and single (RightSidebar), so colors/ratings/
  notes/dates/keywords show the spinner -> check -> X overlay.

Issue 4 — remove Left-sidebar count badges:
- Drop count badges from root folder, Archive, heaps, Notes, and the
  folder tree, plus the now-dead count queries and unused imports. Facet
  drill-panel counts are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:21:04 +02:00
a13e171295 bump post-action delay to 1s for PhotoPrism indexer to catch up
200ms was too short - PhotoPrism's batch archive marks photos in the DB
but the search index (used by /api/v1/photos) updates asynchronously.
1s gives the indexer time to reflect the change before we invalidate
and refetch the timeline.
2026-06-07 22:43:24 +02:00
73c36b4817 revert evictFromCache, add back delay + marks invalidation
Remove evictFromCache entirely - the cache-manipulation approach was
brittle and broke both archive (photos not removed) and tags
(colors/ratings showing empty). Replace with: 200ms delay before
invalidation to let PhotoPrism's indexer process the change, plus
invalidateQueries(['marks']) so tag caches (colors, ratings, notes)
refresh alongside the photo timeline.
2026-06-07 22:31:39 +02:00
82f2a40269 fix: folder loading gets error state + gcTime=0 for fresh refetch
Add a proper error branch to the folder tree so a failed sidecar request
shows an error state rather than a perpetual loading spinner. Also sets
gcTime: 0 so the query re-fetches fresh data when the sidebar remounts
instead of holding onto stale cache across navigations.
2026-06-07 22:10:44 +02:00
f6c0f7a507 fix: evictFromCache only targets infinite queries (not flat caches)
evictFromCache was removing archived UIDs from ALL ['photos']-prefixed
queries, including flat lists like marks-pool and with-notes. This
corrupted the tag drill pages — when navigating to Colors/Ratings after
setting marks, the pool was missing photos and the grid showed empty.
Now only infinite queries (those with a pages array) are filtered.
2026-06-07 22:05:41 +02:00
1df16a6142 perf: evict archived/deleted photos from cache immediately
Remove archived/restored/deleted/approved UIDs from all cached photo-list
pages right after the API confirms, so the grid updates on the same tick
instead of waiting for a network round-trip. Also removes the 400ms
doneBulk animation delay (now unnecessary since tiles vanish instantly).
2026-06-07 21:53:16 +02:00
5da1022ed1 feat: loading toasts for all photo actions
Add loading→success/error toast transition to every bulk operation
(archive, restore, delete, approve, add-to-heap, metadata patch).
Also wires gridKeyNav + CauseGroupCard into the bulkAction store so
keyboard-triggered actions show the same per-tile pending/done/error
feedback as BulkActionBar buttons.
2026-06-07 21:40:18 +02:00
dtoro
da63ad769a feat: bulk action progress — header pill + per-thumbnail states
- New bulkAction store: tracks active/label/detail state for the pill
  and a Map<uid, pending|done|error> for per-tile overlays
- Extract StatusPill.svelte from IndexerStatusPill (generic active/label/detail
  props); IndexerStatusPill becomes a one-line wrapper
- +layout.svelte: render a second StatusPill driven by bulkAction store,
  alongside the indexer pill in the AnimatedMule header
- BulkActionBar: extend withBusy with optional BulkConfig (ids/label/doneLabel);
  pending tiles dim + spinner on start, green checkmark flashes for 400ms before
  cache invalidation removes them; red overlay on error, auto-clears after 2s
- onApprove/batchEdit: wire onProgress callback to setDetail so the pill shows
  the filename currently being processed during fan-out keep operations
- batch.ts: add completedId as third arg to onProgress (backwards-compatible)
- PhotoTile: derive bulkState from store; pending/done/error overlays sit above
  the selection tint; hover-video guarded against pending tiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:43:12 +02:00
dtoro
86e38e152d perf: batch label stats query + fix scoped thumb 2026-06-07 00:33:23 +02:00
dtoro
3757eb0170 fix: use /usr/bin/mariadb-admin for mariadb healthcheck
MariaDB 11 renamed mysqladmin → mariadb-admin; neither the old
healthcheck.sh probe nor mysqladmin is reachable from the Docker
healthcheck exec context. Switch to the full-path binary that is
confirmed present in the container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:23:28 +02:00
dtoro
cfd0c6aa81 fix: rename sidecar() helper → callSidecar() and fix mariadb healthcheck
The new `const sidecar` axios instance (added in 243e5d3) clashed with the
pre-existing `async function sidecar()` fetch helper, causing `npm run build`
to fail with a rolldown redeclaration error. Rename the fetch helper and all
its call sites to `callSidecar`.

Also replace the mariadb healthcheck command: `healthcheck.sh` calls the
`mariadb` CLI which isn't on PATH in the current image layer, causing the
container to stay permanently unhealthy and the deploy webhook to abort before
the `npm run build` step runs — leaving the old JS bundle serving from nginx.
Switch to `mysqladmin ping` which is available in all MariaDB 11 images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:13:01 +02:00
243e5d3831 fix: sidecar response interceptor + folder proxy
- Added 401-handling response interceptor to sidecar axios instance
  (matches existing http instance) so expired/invalid tokens redirect
  to login instead of showing raw 404/401 errors
- Added GET /api/sidecar/folders — proxies PhotoPrism's
  /api/v1/folders/originals with BasePath post-filter
- Updated listFolders() frontend to call sidecar proxy
- Updated plan with remaining fixes
2026-06-06 23:01:38 +02:00
14a1b4e54e frontend: route all photo queries through sidecar timeline proxy
- listPhotos → /api/sidecar/timeline (was /photos)
- countPhotos → /api/sidecar/timeline (was /photos)
- hasPhotosMatching → /api/sidecar/timeline (was /photos)
- Sidecar handler forwards X-Count header for countPhotos()
- Sidecar adjusts X-Count to reflect post-filtered count
2026-06-06 20:17:13 +02:00
7df1c04c0f sidecar: scoped photos/timeline proxy (fixes review + archive leak)
- New GET /api/sidecar/timeline — proxies PP's /api/v1/photos and
  post-filters by FileName prefix matching the user's BasePath
- Also works for review/archive views (q=review:true, q=archived:true)
- Frontend route uses /timeline to avoid Gin route conflict with
  existing /photos/:uid/marks pattern
2026-06-06 20:11:07 +02:00
8f97590d9f sidecar: scoped labels + counts proxy (fixes cross-user label leak)
- New GET /api/sidecar/labels — proxies PP's labels, recalculates
  PhotoCount per user's BasePath via DB query
- New GET /api/sidecar/counts — returns user-scoped sidebar badges
  (all, review, archived, private, photos, videos, favorites)
- Fixed auth middleware to expose userUID and basePath on context
- Fixed ppClient.resolveSession — uses correct endpoint
  (GET /api/v1/session, not /api/v1/session/{token}) and correct
  JSON field names (UID, Name instead of UserUID, UserName)
- Frontend: listLabels now calls /api/sidecar/labels instead of /api/v1/labels
2026-06-06 19:23:22 +02:00
4c08eba27a fix: scope marks, labels, and subjects to the authenticated user
Marks (ratings/color labels) were stored without a user column — every
user saw every other user's marks. Labels and subjects from PhotoPrism's
global endpoints leaked across users because those endpoints ignore
BasePath ACL.

Sidecar:
- Add UserName as composite primary key on Mark (photo_uid, user_name)
- Replace validateSession with resolveSession that fetches the user
  identity from PhotoPrism's session endpoint
- Filter all mark queries by user_name

Frontend:
- Filter listLabels/listSubjects through a BasePath-aware existence
  check — each label/subject is kept only if the user has at least one
  matching photo (single count=1 probe per item, batched at concurrency 8)
- Skip filtering for admin users with empty BasePath (single-user compat)

Also documents USER_BASEPATHS in .env.example — the env var that drives
per-user library isolation via PhotoPrism's auth_users.base_path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 12:36:18 +02:00
6c96c22b33 feat(compose): opt-in GPU overlay for VA-API ffmpeg accel
Layer docker-compose.gpu.yml to mount /dev/dri/{card0,renderD128}
into pp-app, add it to render (992) + video (44) groups, and set
PHOTOPRISM_FFMPEG_ENCODER=vaapi. Hosts without a VA-API device just
skip the overlay (`-f docker-compose.yml -f docker-compose.gpu.yml`
becomes opt-in per deploy).

Drops video transcode + thumbnail generation from CPU to the iGPU
where present — large win for HEVC libraries. README documents the
flag; default behavior on the base compose is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:28:21 +02:00
70dc1b6bdf Merge pull request 'Mulimage 2.0' (#1) from new into main
Reviewed-on: #1
2026-05-21 22:48:54 +02:00
e3d4f6d92e web: SVG favicon (Greek lowercase mu) with light/dark adaptive fill
Use the Greek lowercase mu glyph as the app's favicon. The SVG
carries a `prefers-color-scheme` media query that flips the path
fill between near-black (light mode) and near-white (dark mode),
so it stays legible against any tab-bar background without an
extra browser hint.

Linked before the existing PNG so browsers that support SVG
favicons (Chrome 80+, Firefox 41+, Safari 9+) pick it up; the PNG
remains as a fallback. `apple-touch-icon` keeps the PNG since iOS
home-screen icons can't be SVG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:30:23 +02:00
9fc650fb12 web(sidebar): auto-expand folder tree ancestors of the active folder
When the timeline navigates to a nested folder (RightSidebar's
open-folder icon, URL hydration, back/forward), the LeftSidebar
already highlighted the matching row via filters.folderPath — but
if the parent folder was collapsed in the persisted openSet, the
highlighted row wasn't visible at all.

Each FolderTree instance now runs an effect that adds every
ancestor of the active path to its openSet on filter change. The
root instance expands the top-level ancestor first, which mounts
the next-depth FolderTree instance — and the same effect runs
there, cascading down to the leaf. Persisted to localStorage so
the expansion sticks across reloads.

Skipped in `readonly` mode (heap-convert picker has its own
selectedPath and shouldn't drive the sidebar state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:12:36 +02:00
29f7ad7073 web: deep-link from RightSidebar to folder/map + anchor-mode timeline + fix root count
RightSidebar:
- Folder + Location rows gain a small ArrowUpRight icon button that
  deep-links into the timeline / map view focused on the photo. OSM
  external link removed; the in-app map nav covers the same job.

Folder navigation:
- New navigateToFolder(path, { focusUid, focusTakenAt }) helper in the
  filters store; LeftSidebar's pickFolder collapses to a one-liner that
  reuses it.
- One-shot pending-focus stash carries both UID and TakenAt across the
  goto. URL-watch effect on the timeline consumes the stash so even
  same-folder navigations (where the filter doesn't change) get
  picked up.

Anchor-mode timeline query:
- listPhotosAround(q, takenAt, after, before) issues two parallel
  PhotoPrism calls (`after:<day-1>` oldest-first + `before:<day+1>`
  newest-first), merges + dedupes newest-first. Uses PhotoPrism's
  existing date-only DSL clauses — no server changes.
- When a deep-link stashes a TakenAt, page 0 of the photosQuery uses
  the merged window so the target photo is loaded even for photos
  buried past the standard newest-first cursor. Pages 1+ are disabled
  in anchor mode (PhotoPrism's day-precision cursor would infinite-loop
  on dense days; users see 120 around the target, refresh to drop the
  anchor).
- After page 0 lands, the existing scrollToIndex(targetIdx) expands the
  windowed render set + scrolls the tile into view.

Map view:
- /map honors `?lat=&lng=&zoom=&focus=` URL params, jumping to the
  photo's coordinates at zoom 17 instead of fitBounds-ing the full
  library. Params are stripped after first apply so a manual zoom-out
  + reload doesn't snap back.

LeftSidebar root count badge:
- Now matches what Cmd+A selects in the timeline. Old code used
  /config.count.all (library aggregate, includes archived/hidden/
  review). Switched to countPhotos('', { merged: true }) which counts
  the actual photo entries the timeline lists.
- countPhotos gains a `merged` option; with merged=true it returns the
  response body length instead of the X-Count header — PhotoPrism's
  X-Count is always the file-row count regardless of merged, so a
  HEIC + JPG companion pair inflated the badge to 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:04:23 +02:00
c134afe023 web(review): confidence-aware date guesser with combined filename + path signals
Refactor suggestDateFromPath to combine multiple signals instead of
trying patterns in priority order:

- Filename Y-M-D corroborated by path Y-M/Y-M-D → HIGH (filename-
  agrees-path). Fixes the case where a Samsung-style 20240226_xxx.jpg
  under 2024/02/ was returning the path-only 2024-02-01.
- Filename Y-M-D with no path signal → HIGH (filename-only).
- 10/13-digit Unix epoch in basename → HIGH (unix-timestamp) —
  covers WeChat (mmexport...) and FB saves.
- Path Y-M-D → HIGH (path-ymd).
- Path Y-M only → MEDIUM (path-ym-default-day, synthesised day=01).
  Sidebar row labels these "(estimated day)" so the user knows.

Filename parser now accepts `.` and space separators (covers macOS
screenshots, manual 2024.02.26 renames). Path parser accepts `.` too.
OriginalName participates as a secondary filename signal when present
and different from the on-disk basename.

Patterns we explicitly DO NOT parse, to avoid silent date flips:
DD-MM-YYYY / MM-DD-YYYY, 2-digit years, bare camera sequence numbers.

Add photoNameAndDir(p) helper next to primaryFile so RightSidebar,
BulkActionBar, photoActions, and gridKeyNav all derive {fileName,
path} the same way — fixes the bug where photo.FileName was
undefined on the single-photo detail endpoint and the basename branch
was being skipped entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:04:48 +02:00
a54d90a2d9 web(review): YYYY/MM path fallback + suggestion row below date + tighten 'a' gate
- suggestDateFromPath: when only year+month appear in the path
  (e.g. 2024/01/), synthesize day=01 so date-only foldering yields
  a usable suggestion instead of null.
- RightSidebar: move the suggestion row below the Taken-at input.
- BulkActionBar + gridKeyNav: show the "Accept date & Keep" button
  and fire the bare 'a' shortcut only when EVERY targeted photo has
  a path-derivable date — no more silent approve-without-fix for
  mixed selections.
- gridKeyNav: drop local cachedPhoto duplicate, use the shared one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:41:40 +02:00
97f51a05c4 web(review): scope date suggestion to EXIF Stripped tab + 'a' shortcut
- Gate the sidebar's "Suggested from path" row on /review?tab=stripped_exif
  instead of a per-photo TakenSrc heuristic — PhotoPrism stores a guessed
  TakenAt for stripped-EXIF photos too, so the heuristic was hiding the
  row even when a path-derived date was available.
- Same gate on the BulkActionBar's "Accept date & Keep" button.
- Extract acceptDateAndKeep() + cachedPhoto() into photoActions so the
  bar button and a new bare-'a' shortcut in gridKeyNav share one path.
- Show an 'A' kbd hint on the bar button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:31:04 +02:00
d1ddc48f81 web(review): switch cause tabs to PhotoGrid, add date-from-path suggestion
- CauseGroupCard now wraps PhotoGrid so selection, keyboard nav, and
  preview flow through the standard timeline plumbing. Per-tile hover
  Approve/Archive and the group-wide Approve all are gone — the bottom
  BulkActionBar's review-section Keep/Archive handle single + bulk.
- Low Resolution tab opts into a new PhotoTile dimensionBadge prop so
  WxH stays visible on each tile.
- New suggestDateFromPath util parses YYYY-MM-DD from filename or
  folder path. RightSidebar surfaces it as an amber Apply row above
  the Taken-at input whenever the photo lacks a trusted TakenAt.
- BulkActionBar gains a "Accept date & Keep" button (review section
  only) that patches each selected photo's TakenAt from its path
  suggestion when available, then approves.
- Drop the Same folder / Same camera / Same year strips and the
  RelatedStrip component from the metadata sidebar.

Also bundles in-progress Notes route + tile components and small
tweaks to LeftSidebar, DuplicatesView, CrossFolderGroupCard, and
photoprism.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:20:02 +02:00
55c870c155 web(sidebar): nest Hidden under Review submenu
Hidden is the resting place for photos dismissed during review, so it
groups naturally with the Review subitems. Stays a section-nav button
(keeping its scoped count badge); only Archive remains as a flat Manage
entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:44:43 +02:00
981328faff web(review): expandable Review group in sidebar, tabs become subitems
Mirrors the Tags affordance: chevron-only toggle, no /review landing
entry, navigation only via subitems (cause buckets + Stacks +
Cross-folder linked as /review?tab=<id>). Cause list reuses the
review-groups query so empty buckets stay hidden. The /review toolbar
drops the pill row and shows the active tab as a breadcrumb segment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:42:43 +02:00
e1707c314d preview: keep metadata sidebar mounted across photo changes
Previously the modal's aside was gated on `focusedPhotoQuery.data`, so
each arrow-skim unmounted the sidebar until the next photo's metadata
arrived — which reflowed the preview pane sideways. Now the aside is
always mounted while the modal is open; its contents swap between the
metadata panel and a small InlineLoader the same way the timeline's
right-aside does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:28:12 +02:00
64c0da794d web(sidebar): drop count badges from Map and Tags rows
Per-tag totals are already surfaced by the TagsBrowserSidebar, so the
main sidebar's Map/Tags rows stay as pure navigators. Also removes the
now-orphaned geo, marks, and keywords cache observers that only fed
those badges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:24:53 +02:00
0f4e2e0b8f preview: LQIP + ±2 prefetch + bound action bar to its column
Preview pane paints instantly: a blurred copy of the same thumbnail the
grid loaded (cache hit) rides beneath the sharp fit_1280, which now
carries fetchpriority=high and decoding=async. A $effect prefetches
fit_1280 for the ±2 neighbours so arrow-skim hits the HTTP cache.
Carousel thumbs drop to fetchpriority=low so they yield to the main
image. Skeleton grid gains an mt-2 to breathe against the toolbar.
BulkActionBar moves inside the main column in both PreviewModal and the
/tags drill-in so it no longer stretches under the right sidebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:24:31 +02:00
ea1803ec2f web: 8-color label palette + VIDEO badge in carousel
Expand color labels from the 4-swatch Lightroom culling palette to 8
neutral colors (red/orange/yellow/green/teal/blue/purple/pink) with no
attached semantics, rendered as outlines that fill in when picked.
Carousel tiles now show a VIDEO badge, and the folder row in the right
sidebar always renders ("/" for root) instead of disappearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:56:05 +02:00
fc5f30fad1 fix(sidebar): use count.labels for Tags badge, drop bogus label:* query
PhotoPrism's q-DSL has no "has any label" predicate: `label:*` matches
every photo regardless of label, `label:<slug>` only matches that one
slug, and `keywords:*` behaves the same way. The prior `all:true label:*`
returned a 400 (and the earlier "drop all:true" follow-up made it return
the unfiltered library size, which then fed into tagsTotal and inflated
the parent Tags badge to ~library_size on admin sessions).

Switch labelsBadge to the precomputed `configQuery.count.labels` — the
same source the Tags sub-row's `tagCategoryCount('labels')` already
uses. The parent Tags badge now sums the exact same numbers the sub-rows
display: labels, keywords, people (distinct slugs/keywords/subjects)
plus ratings/colors (photos carrying each mark). Drop the wantScoped
short-circuit since the values are all library-wide now anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:41:07 +00:00
a7b8a60473 web: admin surfaces so the PhotoPrism UI is never needed
- Account tab in General settings — self-service password change.
- UsersDialog (admin-only footer entry) — full /api/v1/users CRUD with
  admin-issued password reset.
- People as a fifth tag category alongside Labels/Keywords/Colors/Ratings,
  backed by /api/v1/subjects and the `person:` DSL clause.
- About tab in Library settings — version, library counts, feature chips,
  and a collapsible env-config help panel for the bits PP has no runtime
  API for (OIDC, TF, WebDAV).
- Library tab expanded with Indexer-advanced, extra Downloads checksums,
  and a Features grid that only renders keys PhotoPrism actually returns.
- Fix the SettingsDialog null-draft race the same way GeneralSettingsDialog
  already had: normalize on open, never null on close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00
24dfa996b3 web: de-brand PhotoPrism references in user-facing copy
Replaces "PhotoPrism" in UI strings (empty states, tooltips, toasts,
log header, login screen) with neutral terms like "the indexer", "the
library", "the server" — accurate regardless of backend. The login
header becomes "Mulimage" and drops the explicit PhotoPrism mention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00
e364e4128f web: unify empty + loading states behind EmptyState/InlineLoader
Replaces ad-hoc "Loading…" text and bare empty messages with two
shared feedback primitives that carry subtle lucide icons, consistent
muted-foreground/destructive tones, and a11y signaling (role=status,
aria-busy, role=alert on destructive empties). Loading copy gains
context ("Loading photos/folders/heaps/metadata…") and the right-
sidebar idle state moves from a "ⓘ" glyph to a MousePointerClick
icon. SkeletonGrid stays as the initial-grid loader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00
d2a76fa58c fix(sidebar): paginate folder counts; drop bogus all:true from labels query
Two distinct bugs were causing left-sidebar badges to under-report:

1. sidecar/folders/counts hard-capped each PP /photos call at count=1000
   and deduped UIDs from that single page. Any folder with >1000 file
   rows under it (typical for a multi-year root scan with HEIC sidecars)
   silently lost everything past row 1000. On this library the root
   badge reported 912 while the year subfolders summed to 1175. Loop
   offsets instead, breaking when PP returns a short page.

2. The Labels-badge query passed all:true label:* to PP, which 400s with
   "Unable to do that" - none of the other bucket queries prefix
   all:true. Drop it; the scoped() helper already injects the user's
   path clause when applicable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:44:43 +00:00
0d5f380948 preview: full-screen modal replaces inline split + tags route reorg
The old SplitGrid + InlinePreview pane is replaced by a full-screen
PreviewModal mounted once at the layout root. Open via Space on the
focused tile or double-click; close on Esc (or X / Space again).
Inside, PreviewPane renders the focused photo, RightSidebar carries
the metadata, BulkActionBar reuses the existing per-photo actions,
and PreviewCarousel windows ±50 thumbs around the focused index.

Selection contract matches the grid: plain click reduces, shift
extends the range, ⌘/Ctrl toggles, plain arrow drops the multi-
selection, shift-arrow extends. New clearBulkToFirst() helper makes
Esc / Clear collapse a bulk back to single-focus on its first member
before the next press fully dismisses (modal closes, grid clears
focus).

Tags route reorganised into /tags/[category]/[[value]] with its own
+layout and TagsBrowserSidebar; the old monolithic /tags/+page is
trimmed to a legacy redirect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:13:20 +02:00
680fa90cbe web: collapsible Tags section + file size on its own row
Groups score, color label, keywords, and auto-labels under one
collapsible "Tags" section on the metadata sidebar (open by default,
choice persists). Moves file size onto its own row with a HardDrive
icon so dimensions and weight read as independent facts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:34:01 +02:00
9bba097d91 repo cleanup: retire legacy mule-image stack, lock PhotoPrism UI to loopback
Delete the old Python+React mule-image stack (backend/, frontend/,
docker-compose.yml, mulita.yml, .env*) plus the one-shot migration and
sample dirs (migrate/, photos-sample/, photovault-app-prompt.md). Only
the PhotoPrism + Go sidecar + SvelteKit web stack remains, so drop the
".photoprism." qualifier from the compose+env filenames.

Bind PhotoPrism's port to 127.0.0.1 so the user-facing surface is just
the SvelteKit web/ app; admin reaches PP's UI via SSH tunnel. Flatten
PHOTOPRISM_INDEX_WORKERS' nested default (podman-compose's interpolator
doesn't expand ${A:-${B:-…}}). Rewrite README for the current stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:30:38 +02:00
155e9bb126 web: single-pointer selection + post-action focus, video debounce, tags metadata sidebar
Selection: plain arrow nav now clears prior multi-selection so exactly
one tile is ringed at a time; shift-extend still grows from the anchor.
onApprove / onRestore / onDelete advance focus via focusAfter(ids)
before clearing selection, matching onArchive.

Preview: defer mounting <VideoPlayer> by 250ms so arrow-skim across
video tiles doesn't open and immediately cancel range requests; hard-
abort the underlying <video> on unmount so the connection releases.

Tags drill view: right-sidebar metadata wired in (single-photo
RightSidebar, BulkMetadataSidebar for >=2 selected), resizable edge
mirrors the timeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:52:16 +02:00
c783f129cc preview: serve fit_1280 + tighten timeline grid padding
InlinePreview was requesting fit_1920 for both the still image and
the video poster — roughly 3× the pixel count of what the pane
actually needs. Drop to fit_1280: still sharp inside the inline
pane (which the user resizes around 300–500px tall in practice)
while cutting payload by ~⅔.

Timeline: pull the grid wrapper padding in from `p-6 pb-24` to
`pr-2 pl-2 pb-2 overflow-x-hidden` now that the SplitGrid preview
pane sits above the grid — the old generous padding existed to
breathe under a full-screen modal that no longer renders inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:25:10 +02:00
24c449f475 feat(preview): fanned deck of cards on multi-select
When `selection.ids.size >= 2`, the inline preview swaps the single-
photo view for a SelectionDeck: each selected thumbnail renders as
an absolutely-positioned card with a translate + rotate computed
from its index in the deck, so the spread reads as a fan. CSS
transition-transform handles the reflow as the deck grows or
shrinks; `in:fly` lands new cards from above, `out:scale` shrinks
removals into the stack. Hash resolution walks the existing
TanStack caches (per-photo + photos-infinite envelope) so the deck
is side-effect-free — no fetches just to render thumbs.

Drop the now-redundant Maximize hover affordance on PhotoTile and
the `onOpenPreview` plumbing through PhotoGrid / +page.svelte:
single-click already places a tile into the inline preview pane,
so the dedicated "open preview" button no longer has a job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:50:40 +02:00
e36f1939c6 feat(preview): inline split-pane preview + sidebar metadata pass
Replace the fullscreen PreviewOverlay with an inline top pane above
each grid surface. SplitGrid + InlinePreview render the focused
photo/video inside the host page; a resizableVertical action drives
the divider and the height persists via the view store. Applied to
the timeline, /tags drill-in, /review cause tabs, /photo/[uid], and
/map. selection.focused is now the single source of truth for both
the inline pane and the right sidebar — preview.svelte store and
PreviewOverlay are removed.

Sidebar: drop the thumb; lead with icon-led filename and folder
rows that match the date/place rhythm. Move dims+size to the top
(below date) and camera/lens/exposure into the collapsible File
section. Read-only spans share the input padding so the text column
aligns across rows. Folder row sits between date and dims+size.

VideoPlayer: stop forcing width/height: 100% so videos honour their
intrinsic aspect ratio inside the pane. Key the player on file hash
in InlinePreview so navigating between videos remounts the element
and autoplay fires again.

Sidebar (LeftSidebar): switch the labels badge to a dedicated
countPhotos('label:*') query so it reports photos with a label
rather than PhotoPrism's category roll-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:46:06 +02:00
Claudio
2a75896274 sidecar: declarative USER_BASEPATHS reconciler
PhotoPrism's OSS edition has no way to map OIDC claims to BasePath, so
every freshly-registered OIDC user lands with BasePath="" and either
sees the whole library (admin) or nothing (guest) — never their own
subfolder.

Introduces a sidecar-driven reconciler with a single env knob the
admin sets in docker-compose / .env.photoprism:

  USER_BASEPATHS="test:test, alice:family/alice, bob:bob"

(`user:originals-relative-path` pairs, comma-separated.) On boot and
every 60s thereafter the sidecar:
  - mkdir -p's the target subdirectory under ORIGINALS_ROOT so
    PhotoPrism's path: ACL filter has somewhere real to point;
  - UPDATEs photoprism.auth_users.base_path for the matching row
    where it differs (idempotent, missing users skipped — they
    materialise on first OIDC login and the next pass catches them).

The reconciler uses a separate gorm connection scoped to the
`photoprism` schema with PhotoPrism's own DB user, since the existing
`sidecar` user only has grants on `mule_sidecar.*`. Connection stays
dormant when PP_DB_PASSWORD is empty — the feature is opt-in via env.

Compose changes: thread PP_DB_* + USER_BASEPATHS through to the
sidecar service. New users.go file isolates the reconciler logic;
main.go calls startUserBasepathReconciler() during boot.
2026-05-18 20:25:19 +00:00
Claudio
85847848c4 Sidebar: scope count badges to current user's library
PhotoPrism's /api/v1/config.count is library-wide and the same value
for every authenticated session. That made non-admins (and admins
with a non-empty BasePath) see badges that didn't match what the
timeline actually showed them.

Replaces the direct `configQuery.data?.count?.<bucket>` reads in
LeftSidebar with per-bucket queries against PhotoPrism's /photos
endpoint. The new `countPhotos(q)` helper sets `count=10000` and
reads the X-Count response header to get the true total in one round
-trip (PhotoPrism's ACL filter is what scopes the result, so the
header reflects "what this session can see").

Each bucket query appends `path:"<BasePath>*"` so admins-with-a-
BasePath stay scoped too; non-admins without a BasePath short-circuit
to `uid:none` (their effective visibility is zero, no point
querying). Admins without a BasePath skip the scoped queries
entirely and keep using the precomputed /config totals — same
network footprint as before for the common case.

Affected badges: Favorites, Hidden, Archive, Review, Tags (labels
component). Map already used `geoQuery` whose result is ACL-filtered
server-side, so its badge is per-user-correct without changes. The
`favorites` field was missing from PpClientConfig.count's TypeScript
type; added it.

Resolves the `test`-user complaint: sidebar showed the admin
library's totals next to Review / Hidden / Archive / Favorites
because those numbers came from /config, not from a user-scoped
query.
2026-05-18 20:11:52 +00:00
Claudio
b0c8c06b2b Sidebar: suppress global count badges for non-admin users
PhotoPrism's /api/v1/config.count returns library-wide aggregates to
any authenticated session, with no per-user scoping. The timeline
itself IS scoped (a guest sees zero photos), but the sidebar was
rendering admin-side totals next to Review / Hidden / Archive / Tags /
Map / root for non-admins — including a freshly-registered "test"
user with role=guest and BasePath="".

Until PhotoPrism gains per-user counters, the SPA now derives an
`isAdminUser` flag and gates every count that's drawn from
configQuery on it. Non-admin users see the labels without badges;
counts re-appear automatically when promoted. Per-folder counts from
the sidecar (which DO scope to BasePath) are unaffected.
2026-05-18 20:02:20 +00:00
Claudio
986dab7334 Clear TanStack Query cache on session change
Sidebar counts, marks, folder counts, etc. were keyed only on query
name, not on the authenticated user. Logging in as a non-admin kept
rendering the previous admin session's data because the cache was
never invalidated. clearSession and adoptSession now wipe the cache
so each identity starts fresh.

User-observed: the "test" user (role guest, BasePath="") saw the
admin library counts in the left sidebar after signing in.
2026-05-18 20:01:17 +00:00
829d7bed83 feat(sidebar,tags): per-tab counts and aggregated Tags badge
- /tags: each tab pill shows its own count (labels/keywords =
  distinct tags, ratings/colors = photos covered). Labels and marks
  queries become always-enabled on the route so every pill resolves
  immediately; keywords stays lazy.
- LeftSidebar: Map badge now reads from the shared `['geo']` cache so
  it matches /map's "N geotagged" footer instead of count.places
  (distinct locations). Tags badge sums the four inner counts;
  keywords contributes lazily once /tags?tab=keywords is visited.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:59:47 +02:00
d70244f17e feat(web): fold duplicates+inbox into /review; sidebar UX cleanup
- /duplicates and /inbox routes removed and folded into /review as
  additional tabs alongside cause tabs; /duplicates keeps a redirect
  for bookmarks.
- LeftSidebar: drop import/inbox tile and favorites; show per-user
  BasePath label at the folder root.
- RightSidebar: split file header into read-only path over editable
  basename (matches sidecar rename contract); date field switches to
  plain-text ISO YYYY-MM-DD (no native datetime picker) with strict
  validation and revert-on-invalid-blur; preserves original hour.
- BulkMetadataSidebar: same ISO-only date input with invalid-state
  styling and apply-button gating.
- BulkActionBar: drop redundant Restore and Undo buttons; ⌘Z still
  reachable via gridKeyNav.
- gridKeyNav: remove favorite toggle (F) alongside the favorites view
  retirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:48:38 +02:00
a38c3c6e9b feat(duplicates): show count badges on Stacks/Cross-folder tabs
Stacks count appears immediately (cheap query); cross-folder count fills
in after its tab is visited (lazy disk scan). Page observes both queries
from cache so badges stay in sync with the panel content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:14:56 +02:00
Claudio
8ac406ac1f Review view: reuse timeline action surface, simpler card UX
The /review cards now share the timeline's PhotoGrid, gridKeyNav,
BulkActionBar, and global selection store instead of carrying parallel
implementations. The card is reduced to a header (title + count +
Dismiss / Archive all), an advisory caption, and a PhotoGrid; keyboard
nav, hover affordances, multi-select, and bulk verbs come from the
shared machinery.

- New web/src/lib/services/photoActions.ts holds the canonical
  dismissPhotos / archivePhotos helpers (toast wording, focus advance,
  undo push, ['photos'] + ['review-groups'] cache invalidation).
  BulkActionBar.onApprove / onArchive and gridKeyNav.approveCullTargets
  / toggleArchive('archive') route through it. CauseGroupCard's
  Dismiss / Archive-all buttons call the same helpers - one code path
  from any surface.

- Approve verb renamed to "Dismiss" across BulkActionBar, gridKeyNav
  toasts ("Kept N" -> "Dismissed N"), and the new review card. The
  BulkActionBar Clear/Dismiss clear button is just "Clear" now so the
  verb only means the action.

- /review sets filters.section='review' on mount and restores on
  unmount, which is what swings the shared action surface into review
  semantics; an effect clears the selection on tab change so a
  previously-selected photo from another cause can't be hit by a new
  tab's bulk verb.

- The route mounts BulkActionBar at the bottom and swaps the right
  aside to BulkMetadataSidebar when selection.ids.size >= 2 - same as
  the timeline; gives the user a one-shot "apply this Date / Caption /
  Keyword to all selected" affordance for EXIF-stripped batches.

- CauseGroupCard drops its bespoke keyboard handler, ResizeObserver,
  focusedIdx state, per-tile hover Approve/Archive buttons, confirm()
  dialogs, and toast.loading worker loop. The unused CauseBadges
  component is removed.
2026-05-18 18:50:23 +00:00
70de4b65ec feat(duplicates): sidebar count + auto-run cross-folder scan
Sidebar Duplicates badge now sums stacks + cross-folder groups, with
cross-folder observed from cache (no eager disk scan from the sidebar).
Cross-folder tab auto-fires the scan on access; button becomes Rescan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:48:20 +02:00
79a9ef49d4 feat(web): scope library views to per-user BasePath
PhotoPrism's user entity carries a per-user BasePath; the web app now
mirrors that scope client-side so each user sees only their own subtree
in the sidebar, timeline, folder counts, and heap-convert target picker.
Admin without a BasePath is unchanged. Also removes the redundant
"✕ <folder>" pill below the folder tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:18:39 +02:00
bd39d310ab feat(sidebar): path next to date + ISO date-only inputs
Moved the filepath display from the readonly facts row to immediately
above the Date Taken editor, and switched both the single-photo and
bulk Date Taken inputs from datetime-local to date (YYYY-MM-DD). The
date-only compare in commitTakenAt avoids clobbering the stored
time-of-day when the user blurs the field without editing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:18:39 +02:00
Claudio
f9f276a986 Review view: tabs per cause (mirrors /tags pill row)
Each cause now becomes a URL-driven tab instead of stacking the cards
vertically. Empty buckets are filtered out by the adapter already, so
the tab row only shows causes with hits. Active tab persists via
?tab=<cause_key>; refresh / share / back land on the same panel.

The standalone ReviewView container is no longer used (the page
renders the active CauseGroupCard directly); deleted.
2026-05-18 15:54:25 +00:00
Claudio
ca9f6e6bd2 Review view: cause-grouped UI with actions + suggestions
Builds a dedicated /review route that mirrors /duplicates' chrome:
- /review/+page.svelte mounts Toolbar + ReviewView + RightSidebar
- CauseGroupCard.svelte renders one card per cause with Approve all
  and Archive all bulk actions plus a per-cause suggestion line
- CauseBadges.svelte shows every matching cause as chips on each tile
- services/adapters/review.ts fetches review:true and groups photos
  by primary cause; current taxonomy is low_resolution >
  stripped_exif > implausible_year > non_image_type > quality_other
  (low_resolution ranks first because it's the most actionable signal)

Sidebar gains an opt-in showRelated prop that adds three
RelatedStrip panels (same folder / camera / year) for the
'decide these together' workflow.

LeftSidebar's Review entry switches from a section filter to a
route link so /review picks up the click.

PpPhoto gains the missing Resolution field PhotoPrism actually
returns on list responses.
2026-05-18 09:03:30 +00:00
9d955d6b94 feat(timeline): muted hover-to-play preview on video tiles
PhotoPrism plays a silent preview of the actual video when you hover
its grid tile; this mirrors that. After a 250ms debounce the tile
mounts a muted, looping <video> over the thumbnail and cross-fades it
in on first decoded frame, so cursor-skimming doesn't fire N requests
and the tile never blanks mid-fetch. The byte-prefetch helper added
in af96922 is now redundant — the hover <video> warms the same caches
on its own.

Also tells Vidstack the playback URL is video/mp4 via a nested
<source>: our /api/v1/videos/.../avc URL has no extension, so
Vidstack's suffix sniff was failing, falling back to a HEAD probe,
and picking the wrong loader (which surfaced as
NS_ERROR_DOM_MEDIA_METADATA_ERR in Firefox).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:20:16 +02:00
af96922e89 feat(preview): inline video player via vidstack + hover-warm prefetch
Replace PreviewOverlay's bare <video controls> with a vidstack-driven
player wrapping the same source URL. Vidstack's default video layout
provides a polished chrome (gradient bottom bar, large play overlay,
hover-revealed scrubber) and registers <media-player>/<media-provider>
custom elements that vendor the browser quirks.

To keep first-frame latency low, PhotoTile starts a hover-warm fetch
of the playback URL after a short (120 ms) delay — a single Range
request of the first 512 KB pages the backend's pre-transcoded MP4
cache file into the OS page cache and lands in the browser's HTTP
cache, so when the player mounts and issues its own bytes=0- request
the response is satisfied from disk. Each hash is warmed at most once
per session; AbortController cancels hovers that don't commit.

The vidstack modules are dynamically imported on mount so they never
run during SvelteKit's static prerender — they side-effect
customElements.define() calls which would crash under SSR.
2026-05-18 08:13:59 +02:00
ccbc1050de fix(web): drop indexer debug log, harden settings dialog, escape search-placeholder quotes
Three small cleanups bundled:

- Remove the `console.debug('[indexer]', ...)` line in the indexer
  store. The PhotoPrism WS protocol is now verified; the log was a
  development aid that no longer earns its console noise.

- GeneralSettingsDialog: normalize cloned PpSettings so `ui` / `search`
  / `maps` are always real objects (some deployments return them
  unset), and re-clone the draft on each open instead of nulling it on
  close. The previous lifecycle let Dialog's exit animation keep the
  form mounted while `draft` was already null, which threw at runtime
  via the `bind:value={draft.ui!.theme}` getters.

- Search-input placeholder string: rewrite as a JS expression so the
  embedded `"vacation"` quotes inside the example don't terminate the
  HTML attribute early. The previous form was a Svelte parse error
  that stopped the dev-server module from loading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:13:42 +02:00
Claudio
cfd85a1fe8 fix(sidebar): root badge shows the library total, not zero
Earlier we (a) wildcarded the per-folder count fan-out in the sidecar
so internal tree nodes (year folders, etc.) recurse, and (b) flipped
the timeline root view to mean "the whole library" instead of
"photos with no path component". The remaining piece — the badge on
the root row — still computed `total - Σ(folderCounts)`, which used
to give the count of root-direct photos. With recursive folder
counts that subtraction double-counts every nested photo (year +
month + …) and clamps the badge to 0.

Use PhotoPrism's authoritative `count.all` directly. That now matches
what the timeline shows under `/` (everything indexed) without an
extra round-trip.
2026-05-18 00:06:01 +02:00
aa63d4c11d feat(sidebar): persist metadata section collapse across photo switches
GPS, Credits & notes, and File sections in the right sidebar now read
and write their expanded state through the view store and persist it
to localStorage. Closed by default; the user's first toggle pins their
choice across subsequent photos and reloads.

Switched from the previous data-driven defaults ("open if this photo
has GPS / IPTC fields") to static defaults: a data-driven default would
change between photos, fire a programmatic `toggle` event on the
<details> element, and silently overwrite the user's persisted choice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:03:38 +02:00
79ec511482 fix(timeline): clear selection after keyboard archive/delete/approve
X (archive/restore), Delete, and S (approve) keyboard handlers in
gridKeyNav advanced focus and invalidated the photos query but never
cleared the selection — so the archived/deleted/approved UIDs stayed in
the SvelteSet and kept their rings on tiles that hadn't unmounted yet.
A subsequent Ctrl-click would then pile new UIDs on top of the stale
set, leaving the user uncertain which photos a follow-up action would
actually target. The BulkActionBar button path already cleared selection
for the same reason; mirror that here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:03:38 +02:00
Claudio
505fef5dfc fix(folders): root shows whole library + sidecar counts recurse
Two paired fixes for the folder tree on the timeline:

* +page.svelte: drop the `Path === ''` post-filter for the root entry.
  PhotoPrism's indexer always nests photos under YYYY/MM, so "photos
  whose Path is empty" is always the empty set in practice — the root
  entry looked broken instead of "whole library". Treat `/` as the
  unscoped view and rely on subfolder selections (now wildcarded via
  filters.svelte.ts) for narrowing.

* sidecar/handlers_folders.go: the per-folder count fan-out used
  `q=path:<x>`, the same exact-match operator that just got fixed in
  the web filter. Result: every year-level folder reported count=0
  in the sidebar. Switch to `q=path:"<x>*"` so the count reflects
  the whole subtree (dedupe by UID still in place).
2026-05-17 23:25:43 +02:00
Claudio
8083328f2d fix(filters): make path:folder query recursive
filtersToQ emitted `path:<folder>` for any non-root folder, but
PhotoPrism's `path:` operator is exact-by-default — so picking the
"2024" node in the folder tree returned zero hits when all photos
lived in date-stamped sub-folders (`2024/01`, `2024/02`, …). PP's
indexer always nests photos under YYYY/MM, so every year-level
folder was empty in the timeline.

PhotoPrism supports a trailing `*` wildcard, so emit
`path:"<folder>*"` instead:

  path:"2024*"     →  matches `2024`, `2024/01`, `2024/02/...`, …
  path:"2024/01*"  →  matches `2024/01` plus descendants — still
                      correct for a leaf folder.

Confirmed against the M0 instance: picking 2024 now returns the full
year's photos; 2024/01 still returns its direct contents.
2026-05-17 23:22:29 +02:00
e669e80a91 feat(web): header pill showing PhotoPrism indexer status
Subscribes to PhotoPrism's /api/v1/ws channel on login and surfaces
index.indexing / index.updating / index.completed events as a small
status pill in the header (next to the AnimatedMule wordmark).

- Shows "Indexing" + the current filename (basename, monospace) during
  the scan pass, "Finalizing — <step>" during faces/counts/folders/
  purge/moments, and "Indexed in Ns" for ~4s after completion before
  fading.
- Per-file events arrive many per second on large libraries — throttled
  to 150 ms with a trailing-edge update so the pill stays calm and
  always lands on the most recent filename. Step and completion events
  bypass the throttle.
- Filename slot is fixed at 24ch so the pill width stays constant
  through a run (no horizontal jitter as filenames change length); the
  full path is exposed via the parent's `title` for hover.
- WS reconnect uses exponential backoff capped at 30 s, and the store
  tears down cleanly on logout so we don't leak sockets across
  identities.

Defensive parsing throughout: PhotoPrism's WS protocol isn't a stable
contract, so unknown event shapes are ignored rather than thrown —
worst-case the pill stays idle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:13:57 +02:00
Claudio
3d8e050af4 fix(compose): cap indexer concurrency to keep shared hosts alive
Default PHOTOPRISM_INDEX_WORKERS is NumCPU/2 (3 on the M0 6-core test
LXC). Each worker forks TF + ffmpeg + libvips, so a fresh index of
~1.2k photos pushed the LXC's load avg above 50 and starved the
Proxmox host. Pin to PP_WORKERS / PP_INDEX_WORKERS (default 2) so
the indexer is calm by default; bump in .env.photoprism on dedicated
boxes.
2026-05-17 23:13:36 +02:00
Claudio
cce1d876c3 fix(compose): pass OIDC env vars under the names PhotoPrism actually reads
The compose file was using PHOTOPRISM_OIDC_ISSUER_URL / _CLIENT_ID /
_CLIENT_SECRET / _PROVIDER_NAME / _REDIRECT_URI, but PhotoPrism's CLI
flags are --oidc-uri / --oidc-client / --oidc-secret / --oidc-provider —
so the env vars it parses are PHOTOPRISM_OIDC_URI / _CLIENT / _SECRET /
_PROVIDER. With the old names PhotoPrism silently ignored them, OIDC
stayed dormant, and `photoprism show config` reported blank oidc-uri /
oidc-client even though everything else looked configured.

Confirmed on the M0 LXC: renaming the env vars makes the Authentik
"Sign in" button appear on /library/login, /api/v1/oidc/login emits a
proper 302 to the IdP authorize endpoint, and the callback creates the
OIDC user + session in the DB.

The user-facing `.env.photoprism` keys are unchanged (OIDC_PROVIDER_NAME,
OIDC_ISSUER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET); the compose file
just maps them to the correct PHOTOPRISM_* targets. OIDC_REDIRECT_URI
is removed because PhotoPrism derives the redirect from PHOTOPRISM_SITE_URL.
2026-05-17 22:56:07 +02:00
7e10f0b462 fix(web): align lazy-PreviewOverlay layout with renamed OIDC bootstrap
Stale import landed when 6b8c7ab rebased on top of the OIDC-rename
commit. The call site updated to bootstrapSessionFromPhotoPrism but
the import line kept the old bootstrapSessionFromCookies name —
svelte-check caught it on the next pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:50:27 +02:00
6b8c7abc20 perf(web): batch folder counts, bounded scroll scan, adaptive thumbs, lazy preview
Six-item frontend performance pass on the SvelteKit app.

P1 — Move per-folder photo counts to a new sidecar endpoint and defer
the fetch to requestIdleCallback. The old client-side path fired one
/photos?count=1000 per folder from the browser (≈1 MB JSON × N folders)
on every cold sidebar mount; the new POST /api/sidecar/folders/counts
fans out over loopback with bounded concurrency and returns a single
{path: count} payload of a few KB.

P2 — Bound the visibleRange scroll-scan around the previous visible
band instead of sweeping every shell from index 0 on each scroll-rAF.
Falls back to a full sweep on cache miss (filter reset, programmatic
jump) so behaviour is unchanged at the edges.

P3 — Adaptive thumbnail size + srcset. PhotoTile now picks the smallest
PhotoPrism tile_* variant (100/224/500) that covers the user's grid
preset at the current DPR. Adds decoding="async".

P4 — Lift the selection check above the {#each} loop. Mostly readability
— SvelteSet.has() is already per-key reactive — but keeps the hot loop
body terse.

P5 — Split dedupedAll / photos derivations so filter-store mutations
(search-as-you-type, section toggles) don't re-walk every loaded page;
only the cheap folder-scope filter re-runs.

P6 — Dynamic-import PreviewOverlay on first preview.uid !== null and
cache the loaded module; closing the overlay leaves the component
mounted with its internal {#if} collapsing the DOM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:48:59 +02:00
Claudio
9a3ad3e579 fix(web): adopt OIDC session from PhotoPrism's localStorage (not cookies)
PhotoPrism's /api/v1/oidc/redirect handler doesn't actually set
auth_token/auth_session cookies — it returns an HTML page that does:

  setItem("pp:<storageNamespace>:session.id",       <session uid>)
  setItem("pp:<storageNamespace>:session.token",    <X-Auth-Token value>)
  setItem("pp:<storageNamespace>:session.user",     <user JSON>)
  setItem("pp:<storageNamespace>:session.provider", "oidc")
  window.location.href = "/library/login";

The deployment's reverse proxy is expected to bounce /library/login
(and /library/*) back to `/`; the SPA then reads PhotoPrism's
storageNamespace from /api/v1/config, looks up session.id and
session.token under that prefix, and adopts the session.

Confirmed via the M0 test instance: prior to this change, server-side
sessions were created on every OIDC return (DB row present) but the
browser had no way to claim them, so the user bounced back to /login.
2026-05-17 22:48:13 +02:00
Claudio
4abe6d758c feat(web): OIDC login button + cookie-based session bootstrap
The SvelteKit /login was username/password only; the legacy comment
even called out 'OIDC SSO ships in M4 when the IdP is wired up'.
Authentik is wired up now, so:

- /api/v1/config exposes ext.oidc when the IdP is configured. Fetch
  it on the login page and conditionally render "Sign in with
  {provider}", which kicks off /api/v1/oidc/login.
- After PhotoPrism completes the auth code exchange, it sets
  `auth_token` + `auth_session` cookies and redirects to siteUrl
  (/library/browse by default; the deployment's reverse proxy is
  expected to bounce that to /). bootstrapSessionFromCookies()
  reads those cookies, calls GET /api/v1/session/<id> with the
  cookie's token, and adopts the resulting session into the SPA
  store on mount.
- Root layout's auth guard now waits for the bootstrap pass before
  punting to /login, so a fresh OIDC return doesn't get redirected
  away before the session is read.
2026-05-17 22:06:33 +02:00
cb5bc120dc fix(web): restore button cursor: pointer affordance under Tailwind v4
Tailwind v4 dropped the default cursor: pointer on <button>, so most
interactive controls (bulk sidebar, star/color pickers, summary
disclosures) had no hover affordance. Add a global base rule covering
button / [role=button] / summary, plus cursor: not-allowed for disabled
states to mirror the existing opacity-50 styling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:00:34 +02:00
a72619e3d1 feat(timeline): focus follows archive, snaps to first on view load
- New selection.focusAfter(excluded) walks selection.order forward past
  the archived/restored set so X-ing through the timeline keeps the
  cursor on the next live photo instead of falling back to photo[0]
  via the auto-anchor effect. Wired into gridKeyNav.toggleArchive (X
  key) and BulkActionBar.onArchive.
- Auto-focus effect on the timeline always re-anchors to photos[0] on
  view load (pageCount → 1), instead of preserving a stale uid from
  the previous filter.
- PhotoGrid re-anchors focus when the previously focused uid isn't in
  the new photo set, so drilling into a /tags category drops the
  cursor on its first tile instead of carrying a stale selection from
  whatever view the user came from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:56:54 +02:00
84e433ff63 fix(timeline): scroll-driven visibility scan, factor PhotoTile + SkeletonGrid
- visibleRange action rewritten to scan [data-uid-shell] divs on each
  rAF-throttled scroll instead of attaching an IntersectionObserver to
  sample tiles. The observer approach broke on return from /inbox: with
  cached photo data, shells mounted in the same Svelte pass as the
  scroll root and tileRegister fired before any __visibleRange stash
  was in place, so registrations dropped silently. Fast scrolling could
  also strand the observer in a dead zone when every sample tile left
  the viewport before the next was mounted. Shells are always rendered,
  so a DOM scan always finds a true first/last.
- Extract PhotoTile + SkeletonGrid so the timeline and the drill-in
  PhotoGrid share one tile chrome (selection animation, badges,
  hover-only "open preview" affordance).
- FolderTree count badge moves inside the row's button so the badge
  area becomes part of the click target instead of a dead zone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:33:03 +02:00
d35de8a2a9 feat(web): /tags tabs + keyword surfacing + dup tab restyle
- /tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors),
  URL-driven with pagination on the label + keyword grids; ratings and
  colors stay as fixed buckets.
- /duplicates tabs (Stacks / Cross-folder) restyled to pill row in the
  Toolbar to match /tags; tab state moved into the route and bound to
  ?tab=...
- New aggregateKeywords() service fans out per-photo getPhoto calls so
  user-typed Details.Keywords surface on /tags (PhotoPrism's /labels
  only returns classifier output).
- RightSidebar renders photo.Labels[] as dashed-border chips after the
  Keywords section, each linking to /?q=label:slug.
- /colors and /ratings routes redirect to /tags?tab=colors|ratings so
  old bookmarks still land somewhere useful; LeftSidebar drops their
  entries and the Tags badge now sums labels + ratings + colors.
- listFolderCounts dedupes by UID (merged=false returns one row per
  FILE, so HEIC+JPG / Live Photo / RAW+JPG pairs were inflating folder
  counts ~2x).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:19:44 +02:00
d5e4f23c0f feat(web): sidebar root folder + folder counts, drill-in colors/tags/ratings, click semantics rework
Sidebar
- New `/` root-folder entry at the top of the Folders group. Active
  when the timeline is scoped to root; the photo grid post-filters to
  `Path === ''` because PhotoPrism's `path:` operator can't express an
  exact-root match. Collapsible chevron, persisted to its own
  localStorage key, and a kebab carrying just "New subfolder".
- Per-folder count badges. `/api/v1/photos?q=path:X&count=1000` per
  folder in parallel via `listFolderCounts`; root count derived from
  `config.count.all − Σ subfolder counts`.
- Folder tree starts at depth=1 under the root so nested rows indent
  visually relative to `/`.
- Footer matches the Toolbar / action-bar h-9 height.

Timeline interaction
- Single click on a tile selects only that tile (clears others); the
  preview now lives on dblclick. Modifier clicks still go through
  `gridKeyNav`'s document handler (shift = range, cmd/ctrl = toggle).
- `x` (archive) now actually archives — PhotoPrism's photo PUT
  silently drops the Archived field, so we route through
  /batch/photos/{archive,restore} the same way the BulkActionBar
  already did. Mirror for `u`.
- Preview close restores the timeline focus + scrolls the last-shown
  photo into view via `forcedExpand`+`scrollTileIntoView` so it
  actually mounts (selection ring would otherwise stay invisible when
  the user navigated far in preview).
- `applyFolderScope` only narrows the timeline to root when the active
  view is a folder view (no heap / search / non-default section), so
  label clicks / heap views / favorites no longer drop subfolder
  photos.

Action bar
- Inline `h-9` row at the bottom of the main column (not `fixed`),
  matching the Toolbar's visual language. Right sidebar stays full
  height — the bar only spans the timeline width.
- Approve action wired for the review pile.

Colors / Tags / Ratings drill-ins
- New shared `PhotoGrid` component owning tile rendering, selection
  styling, single-click-selects + dblclick-previews, and `setOrder`
  for arrow-key nav.
- Each route's drill-in `<main>` carries `use:gridKeyNav` and a
  trailing `<BulkActionBar />` so shift/cmd/ctrl click, arrow keys,
  and the keyboard shortcuts work the same as the timeline.
- Tags switches from `goto('/?q=label:…')` to an in-place drill-in
  with a back button, mirroring `/colors`'s flow.
- Category cards + drill-in photo cards honour the global
  `view.thumbnailSize` (XS–XL) so the timeline's size selector now
  reaches into all four grids.

Settings
- General-settings dialog merges Appearance into UI and switches free
  text inputs to selects for the PhotoPrism theme / language / start
  page / map style (the value-from-server prepends if it's outside
  the curated list so we never silently rewrite a custom value). Time
  zone uses `<datalist>` with `Intl.supportedValuesOf('timeZone')`.

Sidecar
- Heap convert runs reindex synchronously per source path so the
  client's invalidate-and-refetch sees the moved files.

Inbox
- New /inbox route stub for the upcoming import workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:01:41 +02:00
5153aeebec fix(sidecar): podman keep-id mapping so mutations land as the host user
Without keep-id the container's UID:GID maps into the rootless podman
subuid range (524288+), so the sidecar couldn't create
`/photoprism/originals/.duplicates/` — the archive endpoint failed
with "mkdir: permission denied", and rename / folder ops would have
hit the same wall.

The PhotoPrism container already has this override for the same
reason; mirror it for the sidecar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:23:37 +02:00
4362e475a7 build(sidecar): containerize + delete the Node prototype
- New sidecar/Dockerfile: multi-stage golang:1.25-alpine → distroless/
  static, ~12 MB final image, static CGO-free binary.
- Wire pp-sidecar into docker-compose.photoprism.yml so the whole
  stack (mariadb + photoprism + sidecar) starts with one
  `podman-compose up`. Container reaches mariadb + photoprism on the
  internal network; the host gets 127.0.0.1:8000 for Vite's proxy.
- New SIDECAR_LISTEN_ADDR env var (default 127.0.0.1 for the host-mode
  dev loop) so the container can bind 0.0.0.0:8000 and let the port
  mapping reach it. Without this the loopback bind was invisible to
  the host.
- Delete sidecar/legacy/server.mjs — the Node prototype's archival
  window is over; git history is its home now.
- Update sidecar/README with compose-first bringup; keep the host
  `go build` flow as the fast-iteration loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:23:36 +02:00
032dce6c85 feat(sidecar): port Node prototype to Go + Gin + GORM + MariaDB
Replace the Node prototype (server.mjs) with the stack the merge plan
calls for: Go 1.25, Gin for routing, GORM + MariaDB for persistence.
Same wire contract on /api/sidecar/* so the SvelteKit client doesn't
change.

- Marks move from a JSON file on disk to mule_sidecar.marks (auto-
  migrated by GORM on first boot). The Node prototype's marks.json
  was dev-only; not migrated.
- Folder/rename/heap-convert/duplicates handlers reproduce the
  prototype's behaviour, including the path-traversal defence
  (resolveUnderRoot + EvalSymlinks), the size-bucket prefilter for
  the duplicate hasher, and the background reindex fire-and-forget
  pattern.
- Auth model unchanged: requireSession middleware proxies the
  caller's X-Auth-Token to PhotoPrism's /api/v1/photos?count=1
  before any destructive op.
- Expose pp-mariadb on 127.0.0.1:3306 in docker-compose so the
  host Go process can reach mule_sidecar.* without joining the
  container network.
- Archive the Node prototype under sidecar/legacy/server.mjs for
  one cycle as reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:47 +02:00
0766b47bb2 feat(sidebar): library + general settings dialogs, sidebar footer, alignment polish
- Add a Settings cog to the Folders header that opens a tabbed library
  admin dialog (Library / Index / Import / Logs) wrapping PhotoPrism's
  /api/v1 settings, index, import and errors endpoints.
- Add a sticky footer to the left sidebar with the signed-in user's
  display name plus quick-toggle theme, general-settings cog (separate
  dialog for app prefs), and sign-out. Pull these out of the top
  Toolbar trailing slot.
- Align depth-0 folder rows with the rest of the sidebar entries (drop
  the leading chevron column when no children) and bring heap rows in
  line with folder rows so the kebab is part of the row's hover
  background instead of a detached chip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:51:56 +02:00
17df1ecd09 feat(preview): play videos inline in the lightbox overlay
Switch the preview overlay from a still <img> to a <video> tag when the
focused photo's Type is "video". Uses PhotoPrism's /api/v1/videos/:hash
endpoint with the existing previewToken, falls back to a still thumb as
the poster, and autoplays muted so the controls reveal without
clobbering whatever else is on the page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:51:46 +02:00
8c2526d982 feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
Replace the legacy mule-image backend with PhotoPrism plus a thin
SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't
expose (file rename), and add a two-phase migrator (metadata via PUT,
heaps → albums) for the existing Postgres library.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:06:58 +02:00
claudio
423a73a8a6 fix(SettingsDialog): drop dead imports after AI tab removal
tsc --noEmit caught seven TS6133 "declared but never used" + one
TS2614 "no exported member 'features'" left over from a27267f. Strip
Brain/RotateCcw icons, the unused Switch + Loader2 imports, the
adminApi + featuresApi + features module references, and the
SETTINGS_FEATURE_FLAGS_KEY constant. No runtime change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:28:14 +02:00
claudio
a27267f7ad refactor: drop AI/vision pipeline + plain Postgres + full-refresh script
Removes the OpenCLIP-on-ONNX classifier and everything that fed or
consumed it:
  - backend: app/services/vision/, app/tasks/vision.py,
    app/services/feature_flags.py, app/routers/features.py — all
    deleted; admin AI/feature-flag endpoints and the worker-vision
    bootstrap call gone. Photo.needs_review and its index dropped.
  - frontend: AI Settings tab, useFeaturesQuery hook, FeatureFlag
    types, "Needs Review" sidebar entry + filter, needs_review filter
    URL param all gone.
  - infra: worker-vision compose service + models_data volume deleted;
    worker-light command no longer runs bootstrap_models; the db
    image switches from pgvector/pgvector:pg16 to postgres:16; backend
    Dockerfile drops the dedicated torch RUN layer; requirements.txt
    drops torch/torchvision/open-clip-torch/onnxruntime.

Alembic 0019_drop_ai_remnants:
  - drops photos.needs_review + ix_photos_needs_review
  - DROP EXTENSION IF EXISTS vector (must run before the image swap;
    the new postgres:16 doesn't ship pgvector)

New scripts/full_refresh.py: one-shot DB ↔ filesystem reconciliation.
Runs cleanup_data_integrity, scans every active SourceRoot inline
(no celery dependency so the worker can be stopped), hard-prunes
photo + folder rows for files that are gone, removes orphan
/data/thumbs/{user}/{photo}/ directories. New helper
prune_orphan_thumbnails in cleanup.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:20:38 +02:00
claudio
6915c30911 build(backend): cache torch wheel + pip downloads across rebuilds
Splits torch+torchvision into its own RUN layer so edits to
requirements.txt don't invalidate the ~200MB CPU-only torch download.
Adds buildkit cache mounts on both pip-install layers so even when a
layer is invalidated (or buildkit evicts it) the wheel is reused from
the on-disk pip cache instead of refetching from download.pytorch.org.

Triggered by two consecutive deploy failures where pytorch.org timed
out mid-download (2026-05-13 ~21:41 and possibly ~22:47).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:08:10 +02:00
claudio
4e1af0f356 refactor: simplify after self-review
- drop the redundant isLoadingMore boolean from the background-loading
  store; derive from inFlight > 0 in the selector
- pull _active_source_root_paths out of get_library_stats and reuse it
  from get_duplicate_groups (same is_active + admin-scope check, now
  in one place)
- drop p.rstrip('/') in the duplicates folder-scope filter (SourceRoot
  paths are never written with a trailing slash)
- match Timeline's initial-load affordance to the new bottom indicator
  (Loader2 spinner + ellipsis instead of plain "Loading photos...")
- trim narrative comments that explained what the surrounding code does

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:50:17 +02:00
claudio
5e2823ae94 feat(library): tighten duplicates scope, drop in-app upload, polish loaders
- duplicates: restrict the /library/duplicates/groups query to photos whose
  folder path actually lives under an active SourceRoot in the user's
  settings. Nextcloud's "move to trash" flow was leaving .delete/purge-1
  Folder rows wired to the original source_root_id, leaking those entries
  into the Duplicates view as ghost paths that the user never opted into.
- discard: add a spinner to the "Delete N" and "Empty discard pile"
  buttons (and their confirm dialogs) while the destructive mutation is
  in flight, so the user gets immediate feedback for a slow operation.
- timeline: render a bottom-of-grid "Loading more photos…" indicator
  while usePhotosQuery's background cursor loop is still pulling pages.
  Backed by a tiny Zustand store the loop drives via a balanced
  start/stop (counter, not boolean, so rapid filter changes can't flip
  the flag false while a fresh loop is alive).
- remove client-side upload UI + /upload endpoint. Nextcloud is the
  authoritative ingress now; the duplicate path created confusion and
  the backend route is gone too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:44:36 +02:00
root
99edd7d395 ui(thumbs): remove the "date may be wrong" badge
The amber AlertTriangle in the bottom-right of every photo with
has_date_warning set was more visual noise than help — the filter pill's
"Date issues" option still surfaces the same photos when the user
actually wants to triage them. Keeps the BR corner uncluttered.

Backend field + filter pill option unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:55:32 +02:00
root
ab3c55dd96 feat(topbar): drop search box to reclaim filter-bar space
The search box on the right edge of the filter bar wasn't pulling its
weight — kills it entirely along with the supporting plumbing:

- FilterBar: remove input + Search icon import + local/debounced state
- filterStore: drop `q`, `setQ`, plus all references in INITIAL_FILTERS,
  filtersToParams, hasActiveFilters, snapshotFilters
- usePhotosQuery: stop passing q through filtersToParams
- useFilterUrlSync: drop the `q` URL param read/write
- PhotoThumbnail + PreviewView: remove the search-match banner/chip and
  findSearchMatch helper imports
- Timeline + MemoriesView: stop subscribing to / forwarding the prop
- useKeyboardShortcuts: drop the `/` and Cmd+F focus hotkeys
- KeyboardHints: drop the `/` hint and the now-stale `?` collision note
- delete hooks/useSearchQuery.ts (no callers) and lib/searchMatch.ts

Backend /photos/search endpoint left untouched — no UI reaches it now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:53:32 +02:00
root
c30b387dc3 feat(grid): user-configurable thumbnail size for grid views
Adds a "Size" pill in the FilterBar with 5 presets (XS/S/M/L/XL, 96–272px,
default M=160) that drives the cell size in the Timeline, Memories, and
Duplicates grids. Preference persists in localStorage. Preview filmstrip
is intentionally untouched — it's a fixed-track nav rail, not a grid.

Centralised in a new viewSettingsStore so every grid reads from the same
source. Duplicates' virtualizer is poked on size change so row heights
and the keyboard nav's column count stay in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:37:01 +02:00
Claudio
347f58b4f3 perf(thumbs): pool NC client, smaller grid thumbs, eager owner load
Five stacked optimisations for the thumbnail hot path so the timeline
grid lands in fewer round trips and fewer bytes.

1. PhotoThumbnail: switch from 'medium' (640px) to 'small' (240px) for
   grid cells. 240px oversamples 150-200px logical cells on 2x retina
   and drops payload 5-8x. Lightbox and preview filmstrip keep 'large'
   and 'medium' respectively.

2. nextcloud_dav: pool the httpx client. A module-level AsyncClient
   with HTTP/2 + keepalive (max_connections=64, keepalive_expiry=120s)
   replaces the per-request constructor that paid a fresh TCP+TLS
   handshake on every preview fetch. Auth is per-user so it stays at
   the call site via auth=BasicAuth(...). Lifespan-managed: init in
   main.py's lifespan startup, aclose on shutdown. requirements.txt
   gains the http2 extra to pull in h2 (not currently installed).
   Same change applies to fetch_memories_info_async since it hits the
   same host.

3. PhotoThumbnail img: add decoding="async" so JPEG/WebP decode moves
   off the main thread, plus fetchPriority="low" so grid backfill
   doesn't fight UI fetches.

4. Eager-load Photo.user via joinedload from the thumb handler.
   _get_photo_with_share_fallback gains an options parameter so other
   callers stay zero-overhead; only the thumb handler asks for the
   owner join. Eliminates the second SELECT users per request.

5. Disk-fallback path picks up Cache-Control: private, max-age=86400
   in both the FileResponse and X-Accel branches so re-renders match
   the NC primary path's caching behaviour.

Net: a warm grid page should drop from ~200-400 ms median per thumb to
well under 100 ms; payload drops ~5-8x; backend sustains higher
concurrency with fewer sockets to Nextcloud and one fewer Postgres
round-trip per request.
2026-05-12 00:30:43 +02:00
Claudio
68bbe6f024 fix(compose): mount video_cache_data volume on backend + workers
The /playback transcode cache lives at /data/video-cache. That
directory was created in container-local storage (the mkdir at
services/video.py import time), not on a shared volume — so the
worker pretranscode populated its own ephemeral copy while the
backend served from a different empty one, and the cache evaporated
on every container restart.

Add a video_cache_data named volume mounted on backend, worker-light,
and worker-vision so the pretranscoded files actually reach the
serving path and survive deploys.
2026-05-12 00:09:25 +02:00
Claudio
c4df92720b feat(playback): pre-transcode HEVC videos in background; veryfast preset
Long videos blocked /playback for the entire encode duration. The fix
is to populate the cache before the user clicks, not when they click.

Changes:
- Extract ffprobe + ffmpeg helpers to services/video.py so the request
  handler and the background task share one sync implementation. The
  endpoint wraps calls in asyncio.to_thread; celery just calls them.
- New tasks/video.py with pretranscode_video. Idempotent: skips when
  the cache is already current and skips passthrough-safe sources
  (h264 in mp4/m4v/webm). 30-min task time limit so the long-tail
  files (3GP archive, multi-minute 1080p clips) still complete.
- scan_folder now dispatches pretranscode_video alongside
  generate_thumbnails / extract_metadata for any new video row.
- POST /library/maintenance/backfill-video-cache enqueues every
  active video so the existing library catches up.
- libx264 preset bumped from fast to veryfast. ~2x throughput on this
  CPU-only box, output a few % larger but well within disk budget.
- /playback simplifies to: cache check, passthrough if h264 in
  web-safe container, else sync transcode (still there as fallback
  for races against the queued task).

Once the backfill task drains, /playback should be near-instant for
every video. Any video added afterwards is pre-transcoded at scan
time, so the user keeps that property going forward.
2026-05-12 00:03:15 +02:00
Claudio
2a5270d399 fix(playback): force mp4 muxer; ffmpeg cant infer format from .tmp extension
Transcode wrote to {id}.mp4.tmp for atomic publish, but ffmpeg picks
the muxer from the output filename and rejected .tmp with Unable to
choose an output format. Add -f mp4 so the temp name is just storage
scratch, not a format hint.
2026-05-11 22:52:36 +02:00
Claudio
1b6ff45726 feat(playback): transcode HEVC .mov to H.264 MP4 on first hit
iPhone .mov files are HEVC Main 10 with codec_tag hvc1. Safari decodes
that fine; Chrome and Firefox refuse 10-bit HEVC entirely, which the
browser surfaces as "playback is not supported" against the existing
/original endpoint. Confirmed against the user's
26-05-01 13-13-26 0525.mov: codec_name=hevc, profile=Main 10,
audio=aac/48kHz.

New endpoint /photos/{id}/playback handles this transparently:
- check the on-disk cache at /data/video-cache/{id}.mp4 first; serve
  if newer than the source
- passthrough h264 in mp4/m4v/webm containers (ffprobe to confirm)
- otherwise transcode src -> H.264 8-bit MP4 with libx264 fast/CRF 23,
  audio re-encoded to AAC because the iPhone 16 ships APAC audio that
  no browser can decode; +faststart for progressive load
- atomic publish via tmp + os.replace so a failed run never leaves a
  half-written cache entry
- HTTP Range support so <video> can seek the result

The .mov container is excluded from the passthrough fast path because
Chrome/Firefox refuse to play even h264-in-mov reliably, so .mov always
goes through the cache (transcode-or-remux). /original is refactored
to share the new _serve_file_with_range helper.

Frontend getVideoSrc swaps from /original to /playback. /original
stays for downloads and any non-<video> fetches.

First-hit cost is ~9s wall for a 13s 1080p HEVC clip on this box
(software libx264, 4 cores). Long videos are still sync-in-request
because the browser's <video> can't deal with a 202 response; if that
becomes painful, lift the transcode into a celery task with a polling
endpoint.
2026-05-11 22:40:05 +02:00
Claudio
09c12ea35b fix(scan): only resurrect discards when file changed; add Saved toast
The scan_folder resurrect path was unflagging every discarded photo on
every backend boot. start_initial_scan fires scan_all_source_roots on
container start, which fans out scan_folder for every source root,
which walked every file and silently set is_discarded=False on rows
whose file was still on disk -- so every deploy wiped the user's
discard decisions. Today's series of resurrect log lines for
admin/Photos came from that path, not from any actual user re-upload.

Gate the resurrect on os.path.getmtime(file) > discarded_at so the
WebDAV-DELETE-then-re-upload and trashbin-restore-via-PUT-overwrite
flows still trigger (those rewrite the file and bump mtime), but
routine sweeps respect the user's intent. Rows with discarded_at NULL
(legacy) fall through to skipped -- preserve intent over cleanup.

While there: add a Saved toast to the single-photo updateMutation.
The previous patch made cache writes synchronous, which removed the
visible save delay but also removed any signal that the change was
actually persisted. Toast picks a per-field label from the patched
keys (Title updated / Date updated / etc.) and falls back to a count
for multi-field saves.
2026-05-11 22:29:25 +02:00
Claudio
abe5c1ec6b fix(metadata-panel): apply mutation responses synchronously, no second GET
Single-photo updateMutation only invalidated, so the panel waited for
a follow-up GET /photos/{id} round-trip before showing the new value —
felt as a 200–500 ms lag after every taken_at / rating / notes edit.
Use the PATCH response (already the updated row) to merge into the
per-photo cache and patch every cached timeline list in place.

Bulk taken_at had the same shape: invalidate-only, no optimistic. When
the user dropped back from N selected to one of the modified photos
the panel briefly showed the pre-edit value. Move both bulkSetTakenAt
and bulkSetTakenAtMap into useBulkPhotoMutations alongside the rating/
color/notes pattern, with the same snapshot+patch+rollback primitives.

Tags + bulk tags still invalidate-only — separate change if needed.
2026-05-11 21:51:14 +02:00
Claudio
ea08d7e3e8 fix(library/stats): exclude hidden photos from discarded count
The Discarded sidebar entry navigates to /photos?is_discarded=true, which
already excludes is_hidden=true rows (the cross-cutting hidden-folder
filter). The /library/stats discarded_count did not, so the badge could
disagree with the actual list — e.g. dtoro saw 1,281 in the badge but
only 25 in the view because the hidden Memories/ source root holds 1,256
discarded rows. Aligning the count with the view, like every other
sidebar badge already does.
2026-05-11 21:13:26 +02:00
Claudio
356062ead3 feat(date-guess): recognise YY-MM-DD HH-MM-SS Synology export filenames
The 0525.mov-style export from Synology Photos uses 2-digit years, which
the existing patterns ignored (all required \d{4}). Result: filename
gave no signal, suggestion fell through to the YYYY/MM folder layout and
snapped to day 15. The explicit HH-MM-SS half rules out random digit
triples, so we trust YY → 2000+YY for this specific shape and surface
the actual capture time, not noon.
2026-05-11 20:56:47 +02:00
Claudio
7a1c6b618b fix(compose): propagate SECRET_KEY to all workers, not just backend
The workers couldn't decrypt users.nextcloud_app_password_enc because
SECRET_KEY wasn't in their env. _credentials_for() then raised
NextcloudCredentialsMissing and our code swallowed it as "no NC
auth → fall back to local path."

Surfaced on the Phase 4 deploy when /data/thumbs/.../medium.webp
was purged and the vision worker had no disk fallback left. NC
preview fetch then returned None, the classifier got no image,
and the photo failed to classify.

Also masked Phase 3 silently — extract_metadata in worker-light
was falling back to ExifTool every time instead of hitting Memories
(which would have been fine because ExifTool produces the same
fields, but slower and unnecessary). With SECRET_KEY available, the
Memories primary path actually fires.
2026-05-11 13:56:31 +02:00
Claudio
5a67ed7e7b feat(phase 4): vision fetches NC previews; stop writing /data/thumbs
Last consumer of the on-disk thumbnail pipeline was the vision
worker reading /data/thumbs/{id}/medium.webp. Now it asks Nextcloud
for a 640px preview (the same edge size the old thumb used) and
decodes the bytes in-memory — no disk dependency.

- nextcloud_dav.get_preview_bytes: sync sibling of get_preview_async,
  for the celery vision worker (which is sync).
- vision._load_thumb: tries NC preview first; transitional disk
  fallback stays for rows still indexed during the rollout.
- thumbs.WORKER_THUMB_SIZES = set() — generate_thumbnails still runs
  the decode + pHash side-effect (perceptual dedup is mule-only and
  needs original-resolution pixels) but no longer writes thumbnail
  files.

The HTTP thumbnail endpoint's disk fallback path stays in place
unchanged: for NC-404 cases (e.g. iPhone JPEGs mis-extensioned as
.DNG), inline Pillow regeneration still writes a tiny per-photo
file so subsequent requests are fast. That path is rare and the
files are small.

Disk impact: /data/thumbs currently has ~22k medium.webp totaling
~1 GB. They'll stop being read after the worker-vision container
restarts, but no automatic delete — purge with the same find
pattern used for small/large reclaim when ready:

    find /data/thumbs -name "medium.webp" -delete
2026-05-11 13:52:52 +02:00
Claudio
f4618ddf97 fix(rename): refresh Folder.name on webhook-driven directory rename
handle_directory_rename updated Folder.path but left Folder.name as
the old leaf basename. Path is load-bearing; name is purely display,
but a stale name shows wrong text in the sidebar tree until the next
manual refresh. Now sets folder.name = basename(new_prefix) on the
renamed folder itself; descendants keep their existing names because
the rename was on an ancestor (only their paths shift).

Same correctness as the existing PATCH /folders/{id} endpoint, which
also updates both name and path.
2026-05-11 13:46:06 +02:00
Claudio
2a5759cc8d feat(metadata): read from NC Memories first, ExifTool subprocess as fallback
Phase 3 (fat refactor). extract_metadata now tries Memories'
HTTP API GET /index.php/apps/memories/api/image/info/{fileid}
before spawning ExifTool. Replaces ~80–100 ms of subprocess work
with a ~1–2 ms HTTP call for ongoing imports.

What we kept from the ExifTool path:
- Mule's date-fallback chain (SubSec → DateTimeOriginal → CreateDate
  → MediaCreateDate → TrackCreateDate → filename/folder guess → mtime).
  Memories' single `datetaken` field falls back to mtime, which would
  silently mis-date the 6k+ photos in our library that depend on
  filename-encoded dates. _apply_memories_metadata re-applies the
  same chain against Memories' `exif` dict.
- taken_at_source='manual' is still sacred — never overwritten.
- has_date_warning recomputed against the resolved taken_at.

Format compat: Memories' `exif` dict uses plain key names (Make,
Model, ISO, FNumber, DateTimeOriginal, GPSLatitude, ...) while the
old ExifTool path stored `EXIF:Make` etc. PhotoInfoPanel only reads
the four keys above and Memories has them in plain form, so the info
panel keeps working without an adapter. Full-text search (ILIKE on
exif_json) still hits camera names, lens names, dates etc. — value
content is identical, only the keys differ.

Fallback paths preserved:
- 404 from Memories (file not yet indexed by NC's scan, brand-new
  upload): falls through to ExifTool.
- non-NC photos (no nextcloud_fileid or no app password): ExifTool.
- NC HTTP error or parse failure: ExifTool.

CSRF: Memories' /api/image/info/{id} is CSRF-checked. We send
`OCS-APIRequest: true` to bypass it, the same way the OCS clients
do. Auth is the user's existing Fernet-encrypted app password.

Verified end-to-end against:
- IMG_4954.DNG (real DNG with GPS): width/height/lat/lon/taken_at
  match the previous ExifTool output exactly; exif_json switched
  to Memories format (Make/Model/ISO/FNumber preserved).
- 20210817_000000_4A6737B6.jpg (path-dated archive photo): taken_at
  remained 2021-08-17 from the filename heuristic, source='path'.

The `enabled` state of the Memories app is now required for new
imports to skip ExifTool — left enabled in commit 0a4c8d... (NC
admin action; not in this commit).
2026-05-11 13:39:43 +02:00
Claudio
f27f3cb820 fix(handle_directory_rename): iterate in Python — asyncpg rejected SUBSTRING(... FROM LENGTH(...)+1)
The raw-SQL prefix rewrite from f4a03b6 used
`SUBSTRING(filepath FROM LENGTH(:old_prefix) + 1)`. asyncpg's type
inference miscategorises the LENGTH() result and rejects the
parameter as "$2: int (expected str)" at execute time, so every
directory-rename webhook 500'd in production despite the surrounding
logic being correct.

Switch to the same per-row Python loop the existing PATCH
/api/v1/folders/{id} endpoint already uses. Folder renames are rare
and span ≤1k photos typically — the cost of N row UPDATEs is fine.

End-to-end verified:

  RenameTestA -> RenameTestA-FromNC (WebDAV MOVE outside mule):
    nc-webhook renamed (dir): {photos: 2, folders: 2, source_roots: 0}
    DB rows now at -FromNC ✓

  -FromNC -> -ViaMule (PATCH /folders/{id} inside mule):
    mule rewrites synchronously
    webhook fires back ~30s later
    nc-webhook renamed (dir): {photos: 0, folders: 0, source_roots: 0}
    idempotent no-op against an already-updated DB ✓
2026-05-11 13:16:56 +02:00
Claudio
f4a03b63f4 feat(nc-webhook): handle folder rename via NodeRenamedEvent
NC fires one NodeRenamedEvent on a directory rename — children don't
get their own events. The handler bailed on both paths having no
supported extension. Now:

- New `handle_directory_rename(old, new)` in scan.py does a single
  transaction of prefix-rewrites against photos.filepath, folders.path,
  and source_roots.path. Cross-source-root case (Photos/x → Memories/x)
  is treated as discard-the-old-subtree; scan_folder dispatched by the
  subsequent NodeWritten/NodeCreated picks up the new root.

- Webhook renamed branch checks "both source and target are
  directories" and calls the helper. File renames keep the existing
  delete-old + scan-new-parent path.

Idempotent: the SQL matches zero rows the second time around. That
makes the feedback loop safe — mule's existing PATCH /folders/{id}
endpoint already does a WebDAV MOVE + inline DB rewrite for NC paths,
and the resulting NodeRenamedEvent now flows back through this handler
without re-running the rewrite or leaving rows stale.

Trashbin restore (the documented "NC doesn't emit a subscribed event"
gap) is unchanged.
2026-05-11 13:07:28 +02:00
Claudio
94088253f8 fix(nc-webhook): propagate folder deletes + resurrect un-discarded files
Two bugs surfaced by the Phase 2 deletion-roundtrip test:

A) Folder delete in NC only fires one NodeDeletedEvent (for the folder
   itself, no .jpg suffix). The handler bailed with "unsupported
   extension" and photos inside the folder kept is_discarded=false in
   mule until the 30-min discard_missing_photos_beat caught up.

   Fix: when the deleted path has no supported extension, call new
   `handle_directory_deletion()` which UPDATEs every Photo whose
   filepath starts with `dirpath + '/'`. Single SQL statement,
   idempotent (excludes already-discarded rows so re-deliveries don't
   re-stamp discarded_at).

C) PUT-overwrite of a previously-discarded file fired NodeWrittenEvent
   → scan_folder, but scan_folder's "Photo exists by filepath, skip"
   branch left is_discarded=true. File was back on disk; mule still
   treated it as gone.

   Fix: in that branch, if the existing row is discarded, flip
   is_discarded=false + clear discarded_at + re-queue extract_metadata
   so EXIF / nextcloud_fileid pick up any changes to the bytes.

Together these close the gap for "delete then put back" round-trips
via the NC webhook path. Trashbin-restore (bug B in the test report)
remains an NC-side gap — NC doesn't emit any event mule subscribes to
for restore-from-trash. That stays a TODO.
2026-05-11 12:50:52 +02:00
Claudio
f657e2c0ba feat: retire the watchfiles watcher in favour of NC webhooks
End-to-end webhook flow is proven on this NC instance (NodeCreated +
NodeWritten both fired and dispatched scan_folder on a PUT test), so
the watchfiles-based polling layer is no longer needed.

- scanner.start_initial_scan no longer queues watch_folders on boot.
- scan.watch_folders kept as a one-line no-op shim so any leftover
  apply_async in flight from the previous deploy doesn't crash a
  worker. Will be deleted entirely after the queue drains.
- celery.py reroutes watch_folders to the `default` queue (worker-light)
  so the no-op shim actually completes — the `watcher` queue is dead.
- docker-compose drops the mulita-worker-watcher service. Its celery
  --beat responsibility (firing discard_missing_photos_beat every 30
  min) moves to worker-light's command.

Latency note: NC dispatches webhook events through its background-job
queue, currently run by cron */5. After this commit lands you'll want
to tighten cron to */1 so new uploads land in mule within ~60s instead
of up to 5 min.
2026-05-11 12:28:36 +02:00
Claudio
362fbc6d83 feat(nc-webhook): receive Nextcloud file events instead of polling
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.
2026-05-11 12:19:59 +02:00
Claudio
d24c64e0a0 fix(scan): stop auto-queuing backfill_gps on every startup
`_scan_all_source_roots_async` unconditionally dispatched backfill_gps
30s after each container boot. backfill_gps then queued one
extract_metadata task for every photo where latitude IS NULL — which is
most of the library (screenshots, indoor shots, scans, anything without
GPS in EXIF). The result was ~60k extract_metadata tasks piling onto
the default queue at every deploy, pinning worker-light at 180+% CPU
for ~30 min while it re-derived metadata that wasn't going to change.

The "scanned-before-the-GPS-fix" rationale in the original comment
hasn't applied for many releases. Manual trigger via
POST /api/v1/library/backfill-gps is preserved for the rare case where
the extractor really did change.
2026-05-11 12:08:31 +02:00
Claudio
18dce33fa3 fix(original): support HTTP Range so <video> can play .mov etc
`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.
2026-05-11 12:02:44 +02:00
Claudio
28738acb56 fix(backfill): drop offset-based pagination — it skipped filled rows
The offset+limit loop walked the IS NULL set, but every batch's writes
shrank that set, so batch N+1 with offset=N*BATCH skipped over the rows
just filled. A 17k library backfilled only 9k before the loop walked
off the (now-shorter) NULL set.

Replace with a tail-recursive pattern: keep selecting LIMIT BATCH on
the NULL set, tracking rows that won't ever resolve in a `stuck` set so
the loop terminates instead of spinning on them.
2026-05-11 11:43:03 +02:00
Claudio
576b0c236d feat(thumbs): proxy Nextcloud previews instead of duplicating the cache
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.
2026-05-11 11:34:58 +02:00
Claudio
9e9b1ba224 perf(preview): debounce full-res /proxy preload by 400ms
Rapid arrow-nav was firing one /proxy fetch per photo with no way to
abort (new Image() has no abort). Holding the right arrow through ten
photos in two seconds left ten multi-MB transfers in flight competing
for bandwidth and the RAW/HEIC transcoder. Now the preload only kicks
in if the user lingers on a photo for 400ms; otherwise the timer is
cleared and no /proxy request is made.
2026-05-11 11:06:36 +02:00
Claudio
11202a92e7 fix(preview): cull/pick shortcuts target the visible photo, not stale selection
Arrow nav inside preview only updates activePhotoId; selectedPhotos still
points at whatever was selected in the grid before opening preview. X and
S therefore fired against the wrong photo — the toast appeared but the
filmstrip tint for the currently-viewed photo never changed because that
photo was not the cull target.

cullTargets() (and togglePickOnSelection, now sharing it) now prefer
activePhotoId when viewMode === preview.
2026-05-11 10:47:02 +02:00
Claudio
a0d275490b ui(preview): preload thumbs for ±5 neighbors, not just adjacent 2026-05-11 10:32:14 +02:00
Claudio
6311412fc0 ui(preview): load 1280px thumb first, upgrade to full-res in bg
The /proxy endpoint is slow on first hit, especially for RAW/HEIC where it
transcodes synchronously. Preview now renders the pre-generated large thumb
immediately, then preloads /proxy via Image() and swaps src when ready, so
zoom (Z key / wheel) still reaches the original pixels.
2026-05-11 10:22:17 +02:00
claudio
611d445d92 ui(sidebar): ellipsize nextcloud-users/<user>/files prefix in path 2026-05-11 09:51:45 +02:00
Claudio
f14ea69223 ui(duplicates): show grandparent + parent in path strip
When two duplicates live in folders with the same parent name (e.g.
matching '2023' subfolders under different archives), showing only
the parent gave both thumbnails the same label. Walk one level up:
the path strip now renders '…/<grandparent>/<parent>' so the user
can always tell two copies apart at a glance. Filename still
surfaces via the title tooltip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:49:02 +02:00
Claudio
f290784bf3 ui(duplicates): show parent folder + full-path tooltip on each thumbnail
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>
2026-05-11 00:16:48 +02:00
Claudio
f743733edd ui(sidebar): drop Title field, add bulk notes editor
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>
2026-05-11 00:07:02 +02:00
Claudio
c69322a89d ui(sidebar): compact two-section panel, drop active-heap card
The right sidebar had three top-level blocks (ActiveHeapCard + Header
title strip + scroll region with two parallel collapsibles 'Edit' and
'Metadata'). Three nested Section sub-collapsibles inside Metadata
added another row of chevrons per group. A lot of chrome for what is
fundamentally one form per photo.

Refactor:

- RightSidebar: remove ActiveHeapCard import + both usages
  (empty-selection branch and single-photo branch). Single-photo
  branch also drops the redundant Header strip; the new Metadata
  collapsible's trigger IS the visible section title. Multi-photo
  branch keeps Header (still needs 'N Photos Selected').

- PhotoInfoPanel: collapse the Edit and Metadata-with-sub-Sections
  structure into two flat collapsibles. Metadata holds readonly facts
  (Size / Dimensions grid, Path, GPS inlined) and the editable form
  (Filename, Title, Date Taken, Notes, Tags, Rating + Color on one
  row, Flag), separated by a thin horizontal rule. Camera lives in
  its own collapsible at the bottom so a long EXIF block can't crowd
  the form. Default expanded set narrows to ['metadata', 'camera'].

- Compact density: Notes rows=3 -> rows=2, rating + color share a
  row, stars/swatches shrink h-5/w-5 -> h-4/w-4, space-y-2.5 -> 2,
  Flag buttons text-sm -> text-xs, grid gaps tightened. The empty
  'No GPS data' chip is hidden when there are no coordinates rather
  than rendered as an empty row.

- Drop the unused local Section helper and the now-orphan
  ActiveHeapCard.tsx file. Active-heap state stays in the store; the
  Select / Discard buttons inside the form still consult activeHeap
  on click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:43:33 +02:00
Claudio
f63daf16a8 fix(timeline): scroll to top on section / folder switch
Clicking a folder in the sidebar (or any section change) didn't
reset the timeline's scroll position. If the user was scrolled deep
into All Photos, the new folder loaded at the same y-offset, often
landing on empty space below the last row.

The section-change effect already cleared selection and reset the
auto-focus guard; just needed to also reset parentRef.current.scrollTop.
Synchronous so the first paint of the new section is anchored at
photo[0]; the auto-focus selectPhoto call still runs after render
to highlight the first photo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:25:48 +02:00
Claudio
d580796dc8 fix(photos): accept bare-date filter bounds, send T00:00:00 from UI
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>
2026-05-10 23:16:09 +02:00
Claudio
7b153f0d28 fix(metadata): drop EXIF:ModifyDate fallback, prefer SubSec, fall back to path
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>
2026-05-10 23:02:32 +02:00
Claudio
76551d898b fix(nextcloud): NULL parent_id on cross-source-root child folders + spinner
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>
2026-05-10 22:37:12 +02:00
Claudio
89f99d220a fix(photos): coerce tz-aware taken_at to naive UTC before DB write
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>
2026-05-10 22:32:44 +02:00
Claudio
63dd39d172 fix(nextcloud): NULL parent_id before deleting Folder rows on SourceRoot remove
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>
2026-05-10 22:30:34 +02:00
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
99d504842e feat(auth): auto-redirect to Authentik when OIDC enabled
Even when the user has a live Authentik session, hitting
photos.hubris.network used to drop them on the LoginPage with a 'Sign
in with Authentik' button they had to click manually. With OIDC set
up for a single trusted IdP that's friction with no upside.

LoginPage now reads /auth/config on mount and, if OIDC is enabled,
immediately navigates to the OIDC login URL. Authentik recognizes
the existing session and bounces the browser back through the
callback signed in — no clicks needed.

Two escape hatches so the user is never stuck:
  - ?password=1 in the URL forces the password form
  - sessionStorage 'skipAutoSso' flag, set by the logout flow and by
    the OIDC callback's error branch, suppresses the next auto-redirect
    so logouts actually log out and OIDC failures surface their error
    instead of looping straight back to the IdP

While the redirect is in flight we show 'Signing in with Authentik...'
plus a small 'Use password instead' link, so users on a slow or
broken IdP connection aren't left staring at a spinner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:59:15 +02:00
Claudio
eeeb16a0f1 ui(sidebar): split editable vs read-only between Edit and Metadata
The Metadata collapsible was hosting two editable widgets (TagsEditor
and TakenAtEditor) buried inside the readonly sub-sections — Tags as
its own Section, taken-at wedged into Basic Info between size/dims
and the filepath. With both top-level collapsibles in place, the
clearer split is editable up top, readonly below.

Moved into the Edit collapsible (in identification → description →
categorization order):
  Filename, Title, Date Taken, Notes, Tags, Rating, Color, Flag

Metadata now holds only readonly sub-sections:
  Basic Info (size, dims, path), Camera, Location

Dropped the now-empty Tags Section from Metadata and the 'tags' key
from the default-expanded set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:49:44 +02:00
Claudio
1695eae226 ui(sidebar): wrap edit form in collapsible, drop Header X button
Mirror the Metadata collapsible: an 'Edit' wrapper around filename,
title, notes, rating, color, and flag so the editable form is hidden
with one click. Default expanded.

Drop the clear-selection X from the panel Header — Esc still clears
selection and grid clicks do too. The X felt out of place once the
panel restructured around two equal collapsible groups (Edit /
Metadata) below a plain title bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:43:33 +02:00
Claudio
172f869e15 ui(sidebar): pin heap, single scroll area, collapsible metadata group
The right sidepanel had three stacked flex regions (heap card +
header + PhotoInfoPanel) with PhotoInfoPanel maintaining its own
internal scroll. That made the editable fields (filename, title,
notes, rating, color, flag) stick at the top — separate from the
readonly metadata that scrolled below. Effectively two scroll
boundaries on one sidebar.

Move the scroll boundary up so only ActiveHeapCard + Header stay
pinned; editable fields and readonly metadata now scroll together.
Wrap the four readonly sections (Tags / Basic Info / Camera /
Location) in a single outer 'Metadata' collapsible so the user can
hide the whole block with one click. Sub-sections inside stay
individually collapsible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:36:18 +02:00
Claudio
1408ec3fa3 perf(db): partial index for the photos list query
The default photos list (GET /api/v1/photos?per_page=N&sort=taken_at&order=desc)
filters NOT is_trashed AND NOT is_hidden and sorts by
(taken_at DESC NULLS LAST, id DESC). EXPLAIN on the 21k-row table
shows a seq-scan + top-N heapsort (~20ms standalone, multiplied under
concurrent fan-out on page load). The existing single-column
ix_photos_taken_at can't be used because the leading WHERE clause is
two booleans.

Partial index over the sort key, restricted to the visible subset.
Lets the planner index-scan in reverse and stop at LIMIT N.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:28:28 +02:00
Claudio
b1c3ee68dd perf(ui): smaller initial page, slower idle polling
Photos grid was fetching per_page=500 on the very first request, which
serialized hundreds of thumbnail requests behind a single sort+payload.
Split into PER_PAGE_INITIAL=100 (one viewport, fast paint) and
PER_PAGE_BACKGROUND=500 (subsequent prefetch pages, fewer round-trips).

Idle polling for scan-status and worker-status was set to 10s / 15s
respectively. With nothing queued the typical session was firing 4–6
status requests every minute through the single uvicorn event loop on
top of everything else. Bumped both to 30s. While actively scanning /
processing the 2s / 3s cadence is unchanged — that's where the user
actually wants live updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:28:16 +02:00
Claudio
4bb2c959a8 fix(cleanup): distinguish renamed source root from unmounted drive
prune_missing_photos previously skipped every photo whose source root
path didn't resolve, on the assumption that a missing path meant the
underlying drive was unmounted (and silently deleting under those
conditions would be data loss). That conflated 'drive unmounted'
with 'user renamed the folder in their file manager'.

A library with 4,154 orphaned photo rows from a since-renamed Nextcloud
folder hit exactly this case: the /nextcloud-users mount was fine, but
the source root path 'Taco and Muli - 2024 onward' no longer existed
because the user had renamed it to 'Photo Archive 2004-2024'. Every
photo under it was reported as skipped_unmounted forever.

Classify source root state as present/renamed/unmounted by checking
whether the immediate parent is readable. 'renamed' is now treated as
prunable; 'unmounted' still skips. Warning messages differ so the user
knows which fix to apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:24:28 +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
0eee0cecde perf(backend): drop uvicorn --reload, run 2 workers in compose
Production runs were on the dev --reload single-worker config. The frontend
fans out ~15 parallel API calls on first paint (folders/tree, tags, heaps,
sharing/*, stats, photos, worker-status, scan/status); they all serialized
on one event loop and felt slow. Switch to 2 workers without --reload for
real concurrency. --proxy-headers preserved client IPs through nginx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:09:51 +02:00
Claudio
4b137989c6 fix(auth): drop competing 401 interceptor in api.ts
Two response interceptors were stomping on each other:

  1. api.ts (this file, registered at module import) — on 401, set
     original._retry = true, removed access_token from localStorage,
     and rejected. The comment claimed it relied on a "scheduled
     refresh in AuthContext" that does not exist in the codebase.
  2. AuthContext useEffect — proper refresh: POST /auth/refresh, swap
     both tokens, retry the original request.

Axios runs response interceptors in registration order, so api.ts ran
first and pre-emptively burned the _retry flag + access_token before
AuthContext could see the 401. Result: every expired-token request
forced a re-login instead of a silent refresh.

Drop api.ts's response interceptor entirely. AuthContext owns the
refresh dance; the request interceptor here just attaches the bearer.

Companion bump in .env (gitignored): ACCESS_TOKEN_EXPIRE_MINUTES=10080
(7 days), REFRESH_TOKEN_EXPIRE_DAYS=365 — homelab posture, fewer
refresh round-trips per session even when the silent refresh works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:14:45 +02:00
Claudio
f811aae641 fix(sidebar): show Settings entry to non-admin users
Companion to 4c7e981 — the SettingsPage was opened to non-admins but
the LeftSidebar still gated the entry button on isAdmin, so non-admins
had no way to reach it. The page itself is the source of truth for
which tabs and controls are visible per role.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:59:24 +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
4c7e981daf feat(settings): open Library + AI tabs to non-admin users
Non-admins now see Library and AI Features tabs with data scoped to
themselves; only Users (admin management) stays admin-only.

Library tab: queries pass scope=global only when isAdmin, otherwise
omit scope so the backend _owner_filter falls back to current_user.
Stats, worker status, pipeline progress, duplicates, regenerate-thumbs
all respect this. Re-scan + maintenance buttons that hit user-scoped
endpoints continue to work for non-admins.

AI Features tab: feature flag state read via the public /features
endpoint for non-admins (just effective values, no override metadata),
admin-only flag toggle Switches show as disabled with an explanatory
tooltip, and the "Manual pipeline triggers" section (bulk classifier
backfill + rescan-all-source-roots) is hidden entirely for non-admins
since those are admin-bulk operations across every user.

Users tab: stays adminOnly as today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:45:21 +02:00
Claudio
65f6c14487 fix(nextcloud): pin cloud.hubris.network to LAN caddy IP in compose
Without this, the docker default resolver forwards the lookup to the
host gateway, which returns the public IONOS VPS IP. cloud.hubris is
not in the VPS traefik exposure list, so TLS handshakes during
WebDAV calls die with httpx.ConnectError: SSL UNEXPECTED_EOF.

extra_hosts pins it to caddy on 192.168.8.175, which holds the
cloud.hubris.network cert and proxies to the Nextcloud LXC. Applied
to every service for symmetry; only backend currently makes the
WebDAV calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:17:52 +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
11343c17dc ui(sharing): rework share dialog around the Drive/Notion pattern
The previous pass still read as two labeled sections with a target
"chip" that looked like an empty input and a dashed-border empty state
that looked like a drop zone. Rebuilt around the common share-modal
pattern: target name inlines into the title, a single compact invite
row (picker + Viewer/Editor dropdown + Share) sits at the top, and a
hoverable list below shows each person with an avatar, name,
permission subtitle, and an X that fades in on hover.

Also fixes the spacing: DialogContent was p-5 with non-flex children
so the gap utility silently did nothing — switching it to a flex
column puts every section on a 16px rhythm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:34:29 +02:00
3c022cef68 ui(timeline): auto-focus first photo on every section switch
The previous auto-focus guard was one-shot for the lifetime of the
component, so switching from All Photos → Discarded (or any other
filter-based section) carried over the old activePhotoId — and if it
wasn't in the new view, nothing was focused at all. A new effect
watches currentSection and, on any change (or fresh mount after a
Duplicates/Memories detour), resets the guard and clears the stale
selection so the existing auto-focus picks the first visible photo of
the new view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:21:29 +02:00
b6be24c357 ui(sharing): redesign share dialog with clearer structure + affordances
Target is now anchored in a chip at the top instead of a floating line.
Existing shares and the add-user form are split into labeled sections
with states for loading / empty. Each share row gets a hash-tinted
initial avatar and a semantic permission pill (primary = edit, muted =
view). The user picker is full-width with avatars in the dropdown, and
permission becomes a segmented "Can view / Can edit" control alongside
an icon-labeled Share button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:15:44 +02:00
b518a293cd fix(duplicates): reserve 160px row height so thumbnails don't shift on load
The group grid only pinned column width; rows defaulted to auto height,
so each cell collapsed to the size of its still-empty <img> and snapped
to 160px once the thumbnail arrived — visible layout jump, plus the
virtualizer re-measured every group on image load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:15:38 +02:00
b7f2eb7832 ui(timeline): drop redundant tints in discarded + active-heap views
When the grid is already filtered to discarded photos or to the active
heap, every cell would carry the same tint — the grayscale wash or the
green overlay stopped signalling anything and just made thumbnails
harder to read. Timeline now suppresses both when the corresponding
filter is active; the BR icon badges stay for colorblind readability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:56:07 +02:00
96624bf853 fix(duplicates): hoist memo hooks above early returns
Rules-of-hooks violation: useRef and three useCallbacks sat after the
isLoading/isError/empty early-return block, so first render (loading)
called N hooks and the post-data render called N+4, crashing the view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:49:32 +02:00
b9916866b3 ui(footer): move "Built with hubris" byline into LeftSidebar
Pulled the hubris/Roman-year line out of the TopBar and into a new
Footer component rendered below the Settings button in the left
sidebar bottom panel, where it reads as a quiet attribution rather
than competing with the title plate up top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:11:00 +02:00
68d8a6d064 fix(sidebar): exclude watch_folders heartbeat from active task count
The watcher worker reports its periodic watch_folders task as
perpetually active, which kept the sidebar background-activity
spinner running even when no real work was in flight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:31:06 +02:00
e6ca78881f ui(layout): move Date filter inputs to topbar, Active Heap to right sidebar
- FilterBar: new Date pill hosts from/to inputs; calendar stays in left
  sidebar (always visible, no collapse) with reduced padding and a
  taller MONTH_HEIGHT so 6-week months render fully.
- LeftSidebar: drop Library collapse; Heaps regains its chevron toggle
  to match Views/Folders.
- RightSidebar: render ActiveHeapCard above the Metadata header (with
  its own eyebrow); preview overlay reuses RightSidebar so the active
  heap stays visible there too.
- Toaster: top-right, more compact (smaller padding, font, gap).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:21:57 +02:00
root
c5582ffc65 ui(sidebar): collapsible Library section with Heaps nested inside
Wraps Views, Folders, Shared-with-me, and Heaps in a single
click-to-toggle Library section with a consistent h-9 eyebrow header
(matching the new Date header). Heaps keeps its own eyebrow
sub-section so it sits alongside Folders, and heap rows now reserve
the same chevron-slot spacer as leaf folder rows so indentation
lines up across hierarchies. ActiveHeapCard moves to the very top
of the sidebar so it stays visible under any panel state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:48:20 +02:00
root
eac005109c feat(filters): move date picker to sidebar, track visible photo order
Pulls the date range picker out of the filter-bar pill into a
dedicated always-visible section at the top of the left sidebar, and
teaches the timeline to publish its visible photo sequence so "open
first photo" shortcuts respect the on-screen order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:12:34 +02:00
root
45f1649979 ui: uniform 160px thumbnail cells across all grid views
Timeline, Memories, and Duplicates now share a single fixed cell size
(THUMBNAIL_SIZE=160) with no 1fr stretching — cells stay exactly 160px
regardless of sidebar state, at the cost of a small right-edge strip
when the container width isn't a multiple of (160+gap). Width is
measured on the scroll container itself with padding subtracted so
sidebar expand/collapse reliably reflows the grid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:41:40 +02:00
root
66b3bc5e1f perf+ux: cheaper sidebar toggle, virtualised filmstrip, compact toasts
Timeline's items array used to rebuild on every sub-pixel cellSize tick
during the sidebar CSS transition, causing visible jank with thousands
of photos. Row heights now resolve off a ref at virtualizer-measure
time, so items only rebuild when the column count actually changes.
PreviewFilmstrip is horizontally virtualised (~15 cells in the DOM
instead of N), cutting preview open latency on large libraries. Also
honor the user's explicit right-sidebar collapse (don't auto-reopen on
photo selection) and shrink the sonner toasts to a tighter form factor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:24:32 +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
744a7fa0c3 feat(memories): use Timeline's PhotoThumbnail grid
MemoriesView now renders PhotoThumbnail cells wired up to the shared
photoStore so selection, heap membership, preview (double-click /
Enter), badges, drag-to-heap, and search-match highlighting all work
the same way they do in Timeline. Kept the per-year section grouping,
swapped the bespoke img tiles for the shared component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:57:29 +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
a073ee7fb9 perf+style: grid subscription hygiene, a11y, shadcn-style consistency
Perf / a11y (high-impact review items)
- Timeline arrow-key handler binds once per (viewMode, currentSection)
  and reads fresh state via navStateRef instead of an 8-element dep
  array of new-each-render values.
- usePhotosQuery collapses 14 individual Zustand selectors into one
  useShallow selector returning the params object.
- PhotoThumbnail no longer subscribes to the search query directly;
  Timeline subscribes once and passes it down as a prop.
- PhotoThumbnail gains role="button", tabIndex, aria-label, aria-pressed,
  Enter/Space key handlers and a focus-visible ring. Timeline marked
  role="grid"; RightSidebar marked role="region".

Style consistency
- Swap clsx for cn (tailwind-merge aware) across 17 files so
  conflicting utility classes collapse correctly.
- New Badge primitive (ui/badge.tsx) with default/neutral/overlay/
  outline variants; adopted in ColorsView, RatedView, TagsView for
  the repeated count overlay pill.
- Fix palette drift: text-amber-400 -> text-star, text-green-*
  -> text-pick, text-red-* -> text-reject (5 files).
- Button gains an xs size (h-6 px-1.5 text-[11px]) for the repeated
  compact-button pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:30:16 +02:00
e65e798021 perf+ux: cut grid re-renders, coalesce discard, dedup bulk mutations
Frontend cleanup pass driven by the post-shadcn review.

Performance
- Memoize PhotoThumbnail and route cell click/double-click through
  stable handlers so heap-membership invalidation no longer re-renders
  every visible thumbnail.
- Cap usePhotosQuery's eager background page-walk at 20 pages with a
  50ms inter-page yield — was unbounded (up to 100k photos cold).
- Drop the per-thumbnail loading spinner in favour of the existing
  pulse skeleton; only retry state still surfaces a spinner.

UX
- Coalesce rapid X/U presses into a single undo entry + one toast
  (1.2s window) so accidental bursts are easy to back out.
- Optimistic rating/color updates with per-id snapshot rollback on
  error, matching the existing discard pattern.
- Section-aware empty timeline state with a Clear-all-filters CTA.
- Carry the search-match chip from the grid into the preview header.
- Add a basket-icon badge for active heap membership so the green
  tint isn't the only signal (colorblind-safe).
- Standardise error toasts via formatApiError(): FastAPI detail,
  validation arrays, axios message, with a 'Network Error' filter.

Architecture
- Extract useBulkPhotoMutations and stop duplicating
  bulkRating/bulkColor across RightSidebar and useKeyboardShortcuts.
- Split RightSidebar (714 -> 448 LOC) and PhotoInfoPanel (952 -> 716)
  into co-located sub-components: BulkTakenAtEditor, BulkTagsEditor,
  TagsEditor, TakenAtEditor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:13:39 +02:00
7efac4354e ui: migrate to shadcn/ui primitives across dialogs, filters, and forms
Adopts shadcn/ui components (Dialog, Button, Input, Select, Popover,
Command, Checkbox, Switch, Toggle, Calendar, etc.) across the app,
replacing hand-rolled modals, dropdowns, and form controls. Adds a
reusable cmdk-backed MultiSelect for the Type, Tags, and Flag filters
so all multi-value filter popovers share one component and layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:07:20 +02:00
8529771122 ui: rework selection/heap visuals, contextual shortcut hints, inline scan activity
- Selection now reads as a blue ring + tint with a springy scale-down,
  hover stays a subtle gray ring so keyboard-driven and mouse-driven
  states are tellable apart.
- Heap membership is signalled with a green tint only (no badge, no
  ring, no scale).
- Discard/restore is optimistic and non-yanking: photos stay in the
  grid greyed out until the next reload, X toggles based on the
  current state, and the same treatment applies in preview.
- Filmstrip mirrors the grid styling (selection blue, heap green,
  discarded grey).
- Preview close restores the LAST viewed photo as the focused/selected
  one in the grid.
- Right sidebar collapses on view change and re-opens when a photo is
  in focus; Esc clears active selection so the panel collapses too.
- Keyboard hints panel is context-aware (grid / preview / discarded
  section), collapsible with H, persisted, and rendered inside the
  preview column above the filmstrip.
- "Pick (P)" renamed to "Select (S)" everywhere.
- Needs review moved into the Flag pill dropdown.
- Fixed vertical videos overflowing the preview column (min-h-0).
- Replaced the bottom-right ScanProgress popover with an inline
  spinner next to the FOLDERS sidebar header (and on the specific
  folder row being scanned). ScanProgress is now a headless
  invalidator; useScanActivity exposes the live status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:45:36 +02:00
a6eb406052 ui: tidy sidebar tree indent and consolidate sidebar toggles into filter bar
Folder tree now indents 20px per level (chevron width + gap) so a child's
chevron column lines up under its parent's label, and depth-1 rows nest
under the section eyebrow instead of starting flush with it. Spacer for
leaf rows matches the chevron button footprint so rows align regardless
of expandability.

Sidebar open/close buttons (previously split between TopBar and each
panel header) collapse into two toggles at the ends of the FilterBar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:31:10 +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
800ee447ad perf: native canvas markers for map view, drop clustering library
Replace 3K+ React CircleMarker components + MarkerClusterGroup with
native Leaflet L.circleMarker on a shared L.canvas() renderer added
in a single useEffect. Zero React components per marker — canvas
draws all points in one paint (<50ms vs multi-second freeze).

Also drops react-leaflet-cluster from the bundle (-46KB gzipped).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:17:32 +02:00
root
7c68e1400b fix: map view freezing browser with thousands of markers
Replace DivIcon thumbnail markers with lightweight CircleMarkers.
Each DivIcon created a DOM element with an <img> tag, so 3K+
geotagged photos meant 3K DOM nodes and 3K thumbnail requests
hitting simultaneously — freezing the browser during clustering.

CircleMarkers are SVG-rendered on Leaflet's canvas layer with no
DOM nodes per marker. Photos still open in preview on click.

Also: bump cluster radius 50→80, enable removeOutsideVisibleBounds,
disable clustering at max zoom, increase staleTime to 5 min.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:53:58 +02:00
root
ecd8bbe61d fix: HEIC vips fallback, DB pool exhaustion, auth session persistence
HEIC thumbnails:
- Switch fallback from ffmpeg to vips for HEIC files that pillow-heif
  rejects. ffmpeg decoded gain map tiles instead of the primary image,
  producing inverted/negative thumbnails. vips uses libheif's item
  references correctly and extracts the full-resolution primary image.

Database pool exhaustion:
- Add idle_in_transaction_session_timeout=60s so Postgres auto-kills
  leaked connections from disconnected thumbnail requests.
- Add pool_timeout=10 so new requests fail fast instead of hanging.
- Bump pool from 5+5 to 10+10 for thumbnail concurrency headroom.
- get_db rolls back on exception before closing.

Auth session persistence:
- Narrow 401 interceptor exclusion to only /auth/refresh and /auth/login
  (was excluding all /auth/* including /auth/me, preventing token refresh
  on boot).
- fetchMe only clears tokens on 401/403, not network errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:25:55 +02:00
root
2adaaf18a1 fix: ffmpeg fallback for HEIC files that libheif rejects
iPhone photos with depth maps or gain maps have too many auxiliary
image references for libheif 1.17, causing pillow-heif to throw
"Too many auxiliary image references". process_heic_image() now
falls back to ffmpeg when pillow-heif fails — ffmpeg's own HEIC
decoder handles these files without issue. Fixes 27/35 HEIC photos
that were stuck in failed state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:08:31 +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
180efb3eb0 fix: admin scan skips other users' source root directories
When the admin's source root is the mount root (/photos) and other
users have subdirectories (/photos/bob), the admin's scan now prunes
those directories from os.walk so photos aren't double-indexed under
the wrong user. The scanner queries all active source roots owned by
other users and excludes their paths during directory traversal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:04:21 +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
bbb8e4850c feat: GPU acceleration support for ONNX Runtime inference
Centralize execution provider selection in providers.py with
auto-detection and graceful fallback. All ONNX sessions (embedder,
detector, face processor, recognizer) now use the configured providers.

- New VISION_EXECUTION_PROVIDERS env var: "auto" for GPU auto-detect,
  or explicit "CUDAExecutionProvider,CPUExecutionProvider"
- Provider priority: CUDA > ROCm > OpenVINO > CPU (when set to "auto")
- docker-compose.yml includes commented-out NVIDIA GPU deploy section
- Supports onnxruntime-gpu as a drop-in replacement for onnxruntime

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:12:21 +02:00
94c07b1d0d feat: upgrade to SigLIP2 ViT-B/16 for semantic search
Replace OpenCLIP ViT-B/32 (512-d, ~78% recall) with SigLIP2 ViT-B/16
(768-d, ~84% recall) as the default embedding model for significantly
better image-text retrieval quality.

- New SigLIP2Embedder class with 384px input and SigLIP normalization
- ONNX export pipeline for SigLIP2 visual + textual encoders
- Migration 0010: resize embeddings.vector from 512 to 768 dimensions
- Config-driven model selection: "siglip2_vitb16" (default) or
  "openclip_vitb32" (legacy) — both models can coexist
- Content classifier follows the configured embedder family
- Existing embeddings cleared on migration; vision backfill regenerates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:09:16 +02:00
c7dd03ade2 feat: CLIP-powered incremental duplicate detection
Replace O(N²) pHash-only duplicate detection with a hybrid approach:
- pHash Hamming distance for exact/near-exact copies
- CLIP embedding cosine similarity via pgvector HNSW for visually
  similar photos (crops, format changes, screenshots)

Post-scan now uses incremental mode: only newly added photos are
compared against the full library — O(new × log N) via HNSW index
instead of O(N²). Full regroup remains available from Settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:03:04 +02:00
8f41a23c41 feat: switch frontend to cursor-based pagination
Replace page-number walking with cursor chaining in usePhotosQuery.
Each response includes a next_cursor that seeks directly to the next
slice via an indexed range scan — O(1) regardless of depth instead of
OFFSET-based skipping that degrades on large libraries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:56:57 +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
root
03a4c75e3e fix: reduce db pool size to prevent connection exhaustion, sort grouped views by count
Pool was 20+10 overflow per engine; uvicorn --reload leaked pools until
Postgres hit max_connections=100. Reduced to 5+5 with pool_pre_ping.

Grouped views (tags, people, colors, rated) now sort cards largest-first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:32:05 +02:00
root
d933ea3842 feat: auto-chain vision backfill + face recluster after scan, show progress in UI
Scan now automatically queues backfill_vision (+90s) and recluster_faces
(+300s) after dispatching folder scans. Face extraction also schedules a
debounced recluster via Redis so incremental file-watcher imports get
clustered without manual intervention.

The ScanProgress widget now tracks worker queue activity beyond the scan
phase, showing a "Processing Photos" indicator with vision queue counts
while background tasks (embeddings, faces, tags, OCR) are running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:54:27 +02:00
6457dc9da5 fix: bottom padding on views so last row clears keyboard hints pill
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:51:00 +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
64e4ea8083 feat: floating capture-date chip on timeline scrollbar
Tracks the scroll position and shows a small date chip pinned to the
right edge of the timeline, fading in while the user scrolls and out
700ms after they stop. Only active in date-sorted views — other sort
modes hide it since the label would be meaningless. A cached
row-offset/date index keeps the lookup to a single binary search per
scroll frame.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:01:52 +02:00
dcf6c11a22 refactor: fold date-warning filter into Flag pill
Rolls the standalone "Date issues" toggle back into FlagFilter as a
third value ('any' | 'discarded' | 'date_warning') so the date-warning
control lives in the same popover as Discarded, where operators expect
all flag-style filters. Drops the redundant dateWarning boolean and
its URL param.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:54:35 +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
root
afe420c620 fix: use CPU-only PyTorch and run init_db before alembic migrations
PyTorch default install pulls ~7GB of CUDA libs, exceeding disk on small
VMs. Switching to CPU-only saves ~6GB. Also run create_all before alembic
so migrations find existing tables on a fresh Postgres.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:23:32 +02:00
532932057f style: mule ASCII placeholder in sidebar, bump muted text contrast
Replace Info icon with braille mule art in the right sidebar empty
state. Lighten text-muted (#a8997d → #c4b599) and text-faint
(#5e5448 → #7a6e5e) for better readability across the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:13:54 +02:00
a4f64fad58 docs: update README for vision pipeline and Postgres stack
Reflect current state: Postgres+pgvector replaces SQLite, vision
pipeline (YOLO, CLIP, InsightFace, OCR) is shipped, card-grid browse
views for tags/colors/ratings/people, map view, duplicate detection,
and semantic search are all live. Remove completed items from future
features.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:50:28 +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
fa9b21856f feat: replace face pipeline with InsightFace, add content classifier
Face detection/recognition:
- Replace YuNet + SFace with InsightFace buffalo_l (RetinaFace + ArcFace)
- 512-d ArcFace embeddings (was 128-d SFace), migration 0006 resizes column
- Remove YOLO person-bbox workaround — RetinaFace is accurate enough
- Detection threshold 0.65 cleanly separates real faces (0.72+) from
  false positives on dogs/paintings (0.56-0.61)

Content-type classification:
- CLIP zero-shot classifier using native PyTorch text encoder + ONNX
  image encoder for high-quality text-image similarity
- Categories: photograph, screenshot, document, receipt, meme, artwork
- Writes Tag(kind=content_type) per photo via photo_tags
- Margin-based confidence: top-1 vs top-2 score difference
- New ClassifierSettings in config (enabled, min_confidence)
- Wired into vision_fanout pipeline

Tested: 6 real faces from 4 photos (zero false positives), 11/13 photos
classified (8 photograph, 2 artwork, 1 meme).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:49:02 +02:00
f48e099bd2 fix: verify faces against YOLO person detections for precision
Cross-reference face detections with YOLO 'person' bounding boxes —
only keep faces that overlap >= 50% with a detected human body. This
eliminates false positives on dogs, paintings, and cartoons without
needing an aggressive score threshold.

Lower face detection threshold back to 0.6 since the person-overlap
check is now the primary precision filter.

Tested: 6 verified faces from 4 photos, zero false positives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:13:01 +02:00
40d570f2c2 fix: raise face detection threshold to 0.85, tighten clustering
Eliminates false positives (dog faces, painting faces) by requiring
score >= 0.85. Tighten cluster eps to 0.25 for better separation.
Tested: 4 real faces from 2 photos, no false positives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:09:43 +02:00
5f3fa5240e fix: search endpoint — correct column name and DISTINCT/ORDER BY
- Fix Photo.created_at → Photo.added_at (column doesn't exist)
- Fix Postgres DISTINCT + ORDER BY conflict by using a subquery for
  tag_id filtering instead of JOIN + DISTINCT on the outer query

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:55:55 +02:00
aba061dd43 fix: People view shows person's photos inline instead of navigating away
Clicking a person card now opens a detail sub-view within the People
section showing their photo grid. Back arrow returns to the card grid.
Photos are clickable to open the preview. Rename is available in both
the card grid and the detail header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:51:22 +02:00
229611b4c3 fix: face clusters write photo_tags, raise detection threshold to 0.7
- recluster_faces now writes photo_tags rows for each face cluster so
  the tag count and tag_ids filter work (previously count was always 0)
- Old cluster tags and photo_tags are cleaned up before re-clustering
- Raise face detection threshold from 0.4 to 0.7 to reduce false
  positives (was detecting dog faces as people)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:48:19 +02:00
db20cbb7d8 fix: face detection — use OpenCV FaceDetectorYN and full-res originals
- Rewrite faces.py to use cv2.FaceDetectorYN instead of raw ONNX
  (handles multi-scale anchor decoding and NMS internally)
- Load original photo files at up to 4000px for face detection instead
  of 240px thumbnails — faces were too small to detect at thumbnail res
- Falls back to thumbnail if original is unavailable

Tested: 33 faces extracted from 13 photos, clustered into 1 person.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:43:29 +02:00
17a69a271e feat: add People view with person cards and click-to-filter
Replace the tag-grouping people section with a dedicated PeopleView:
- Grid of face cluster cards showing representative photo thumbnail,
  person name, and photo count
- Click a card → navigates to all-photos filtered by that person's tag
- Inline rename via pencil icon on hover
- Empty state when no faces have been clustered yet

Wired into App.tsx as a section-level route alongside Map and Duplicates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:32:20 +02:00
7558aeb5e6 fix: sync DB sessions in Celery, letterbox YuNet, dedupe detections
- Rewrite all vision tasks to use sync psycopg2 sessions instead of
  asyncpg — fixes 'another operation in progress' and event loop errors
  when Celery forks workers sharing the async connection pool
- Letterbox-pad images to exactly 640x640 for YuNet face detector
  (was crashing on non-square thumbnails)
- Deduplicate object detections per label per photo — keep highest
  confidence only to avoid photo_tags PK violation on multiple
  detections of the same class
- Add all queues (-Q default,high,low,vision) to worker command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:28:17 +02:00
2a6661f779 fix: model weights setup — export scripts, ORT compat, bootstrap
- Add export_models.py for OpenCLIP ViT-B/32 and YOLOv8n ONNX export
- Fix ArgMax(13) ORT ARM64 incompatibility by passing eot_indices as a
  separate ONNX input (computed outside the graph in embed.py)
- Use legacy TorchScript exporter (dynamo=False) for IR version 9 compat
- Upgrade onnxruntime to 1.18.1
- Rewrite bootstrap_models.py with clear separation of auto-downloadable
  models (YuNet, SFace) vs manually-exported ones (OpenCLIP, YOLOv8n)
- Wire bootstrap into worker CMD (runs before Celery)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:41:25 +02:00
29177f0c1a feat: wire frontend to vision pipeline search and tags
- Add search API client (POST /photos/search) and useSearchQuery hook
  for hybrid FTS + semantic search with RRF ranking
- Extend Tag type with kind, source, representative_photo_id fields
- Add tags.merge() API method
- Update useTagsQuery to accept optional kind filter
- Add People section to sidebar (face clusters from GET /tags?kind=face_cluster)
- Sidebar Tags count now shows user tags only; People shows face clusters

The existing GET /photos?q= flow is preserved for browsing; the new
search hook activates when the search box has a non-empty query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:19:47 +02:00
ad007e4cd4 feat: add face detection, recognition, and clustering
- Create face_embeddings table with pgvector Vector(128) + HNSW index
- Implement extract_faces task (YuNet detection + SFace recognition)
- Implement recluster_faces task (DBSCAN clustering → Tag(kind=face_cluster))
- Clusters are named "Person N" and get representative_photo_id
- cluster_id FK → tags.id, SET NULL on delete for merge/rename support

Migration 0005 creates the face_embeddings table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:13:41 +02:00
1ebc4bfe73 feat: add YOLO object detection writing to unified Tag model
Implement detect_objects Celery task:
- Runs YOLOv8n on 640px thumbnail via ONNX Runtime
- Creates Tag(kind=object) rows for each COCO class detected
- Writes photo_tags associations with confidence, bbox, and source
- Wipes previous detections per source model on re-run

No new tables/migrations — uses the unified Tag model from PR3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:11:26 +02:00
842a4fc864 feat: add OCR text extraction and Postgres full-text search
- Create ocr_text table for storing per-region OCR results
- Add tsvector search_vector column to photos with GIN index and
  auto-update trigger on filename/user_title/user_notes
- Implement ocr_photo Celery task using rapidocr-onnxruntime
- Add FTS leg to hybrid search: queries photos.search_vector and
  ocr_text via UNION, fused with semantic results via RRF (k=60)

Migration 0004 backfills search_vector for existing rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:10:14 +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
9282a5c734 feat: add vision pipeline scaffolding with ONNX backend
Introduce the app/services/vision/ module with ABC interfaces, ONNX
Runtime backend, model registry, and per-task implementations:
- OpenCLIP ViT-B/32 embedder (image + text, 512-d)
- RapidOCR engine (PP-OCRv4 via ONNX, no PaddlePaddle)
- YOLOv8n object detector (raw ONNX, no ultralytics runtime)
- YuNet + SFace face processor (Apache 2.0, opencv_zoo, 128-d)
- DBSCAN face clustering helper

Add VisionSettings to config (mulita.yml + Pydantic), bootstrap_models.py
for first-boot weight downloads, models_data Docker volume, and ROCm
backend stub for future GPU acceleration.

No Celery tasks wired yet — models load but nothing invokes them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:00:06 +02:00
dea04ceed9 feat: migrate to Postgres + pgvector with Alembic scaffolding
Switch the default database from SQLite to Postgres + pgvector (via
pgvector/pgvector:pg16 Docker image) to support the upcoming vision
pipeline (embeddings, OCR, object detection, face clustering).

- Add `db` service to docker-compose.yml with healthcheck
- Wire `alembic upgrade head` into backend CMD before uvicorn
- Bootstrap empty 0001_baseline revision (schema still owned by create_all)
- Guard SQLite-only PRAGMAs and inline ALTERs behind _is_sqlite flag
- Run `CREATE EXTENSION IF NOT EXISTS vector` on Postgres init
- Add asyncpg, psycopg2-binary, pgvector to requirements
- Provide docker-compose.sqlite.yml escape hatch for legacy SQLite mode

Fresh DB + rescan assumed — no SQLite→Postgres data migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:46:20 +02:00
f01b5ed77e feat: snappier timeline — instant discard, progressive load, restore preview origin
- Discard yanks photos from the grid optimistically (cache strip + active
  cursor advance) instead of waiting for the mutation round-trip; wired
  through the X hotkey, RightSidebar bulk discard, LeftSidebar discard
  drop, and DiscardActionBar restore/delete.
- usePhotosQuery resolves on the first 500-photo page and streams the
  remaining pages into the cache in the background, so the first
  thumbnails paint immediately on large libraries.
- Closing preview restores the photo it was opened on (snapshot ref in
  PreviewView, written directly to the store) and Timeline scrolls that
  row back into view. Escape is handled on the dialog with
  stopPropagation so Timeline's window-level Esc handler doesn't wipe
  the restored selection.
- Preview overlay bumped to z-[1000] so it covers Leaflet map tiles,
  and the right sidebar no longer collapses during preview — both fix
  visible layout shifts on close.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:37:33 +02:00
b2ebf401bb chore: text format 2026-04-09 23:48:16 +02:00
57788f60c5 chore: move Tags section directly under Color label
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:48:02 +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
9c9f5bd899 chore: change text style 2026-04-09 20:43:33 +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
c973ca0443 feat: colors view and color label thumbnail ornament
Adds a Colors entry under Views that buckets photos in canonical
R-O-Y-G-B-P order (plus an Uncolored tail), mirroring the rated/tags
grouping pattern. Surfaces the color label on each thumbnail as a
small swatch chip preceding the rating badge in the BL corner so
labels are visible everywhere, not just inside the new view. Also
types color_label on the Photo interface — the backend already
returned it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:41:06 +02:00
870e7dd3d3 fix: refresh sidebar counts after photo mutations
LIBRARY_STATS_QUERY_KEY was never invalidated by the bulk/single
mutation paths in RightSidebar and PhotoInfoPanel, so the All Photos
/ Rated / Discarded / Tags badges only updated on reload. Add it to
both shared invalidation helpers and the inline updateMutation, and
correct the stale docstring on useLibraryStatsQuery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:35:17 +02:00
30cbcb8cd1 feat: group rated view by star rating
Mirrors the tags view: rated section now buckets photos 5★→1★ with
an "Unrated" tail group, instead of a flat ratingMin=1 stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:30:44 +02:00
03b99a40b0 fix: lock left sidebar row height on hover
Switch tree and heap rows from py-0.5 to a fixed h-[24px] + leading-none
so the hover-only kebab buttons can't stretch the row vertically as the
pointer crosses them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:22:13 +02:00
b993a188d3 style: tint topbar "Built with hubris" footer to black/50
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:22:13 +02:00
367d1277b8 fix: stronger drop shadow on heap card thumbnails
Bump the ActiveHeapCard stack thumbnails to a layered drop shadow and
a darker ring so they lift cleanly off the softened desert backdrop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:11:33 +02:00
7d1149aabd feat: themed scrollbars and chiseled thumbnail chips
- Replace browser scrollbars with slim 6px overlay pills that tint to
  primary on hover (transparent track, Firefox + WebKit).
- Rework the thumbnail badge family: drop the mismatched white ring
  halo for a 1px dark frame + inset top highlight, and swap rounded-
  full pills for rounded-sm chips so the ornaments match the pixel-art
  aesthetic used elsewhere in the app.
- Soften the ActiveHeapCard desert backdrop so the fanned thumbnails
  read on top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:09:49 +02:00
8f25c58b74 refactor: tighten sidebars with eyebrow section headers
Compact the left/right sidebars and the photo info panel: shrink panel
widths, drop header height, switch top-level tree groups and metadata
sections to small uppercase eyebrow labels, and tighten row padding,
icon sizes, and count badges throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:03:22 +02:00
12a616d4b5 fix: align search input height with filter pills
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:53:17 +02:00
2e7158a5bb feat: desert dusk theme with animated mule and pixel-art scenery
Replaces the cool-grey neutral theme with a warm desert dusk palette
pulled from a new pixel-art TopBar: a tiled, right-to-left scrolling
desert under a sky gradient, a 6-frame walking mule sprite where the
logo used to sit, and an ASCII block-character title on a black plate.
The active heap card now uses a cactus/dune scene as its stack
backdrop, and the "Built with hubris" mark moves into the TopBar's
bottom-right corner (AppFooter component removed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:49:13 +02:00
938c5dce68 refactor: unify thumbnail ornaments into one chip family
Every overlay on a thumbnail now composes the shared THUMB_BADGE_* classes
(one shape, one height, one ring, three semantic colour variants: primary
for user state, neutral for metadata, pick for auto-suggested best). RAW
badges, BEST pill, Keep-this button and the dimensions chip — previously
three different styles — join the family, is_duplicate moves to neutral
since it's file metadata not a user decision, and the selection check
shrinks to match the rest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:57:55 +02:00
6d8e92940a feat: auto-focus first photo on timeline mount
Arrow-key navigation required a click to establish an active photo
first. Now the grid auto-selects photos[0] on initial mount when no
active photo is set, so the user can land on the app and immediately
walk the grid with arrows. Guarded on viewMode === 'grid' and
!activePhotoId so we never clobber an existing selection or fight
with PreviewView.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:16 +02:00
f4c51f6f92 fix: make keyboard hint pill legible over busy thumbnails
The bottom-center hint bar used bg-surface/40 + text-text-muted, which
left busy photos leaking through and washed the text out — users
couldn't actually read the shortcut labels when a bright thumbnail sat
directly behind the pill. Switched to a near-opaque bg-black/80 with
white-alpha text + white/15 key caps. Backdrop blur stays for the
subtle glass feel, but the contrast is now unconditional on the
photo underneath.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:44:04 +02:00
64b84e2069 feat: active heap card in left sidebar with subtle toasts
- New ActiveHeapCard pinned just below the Library header in the left
  sidebar. Renders null when no heap is active. When one is, shows the
  heap name + member count + a fan of the last 5 member thumbnails
  (newest front-and-center, older members rotated ±6°/±18px outward).
  Clicking the header navigates to the heap via the same
  navigateToSection pattern HeapsPanel uses.
- Stack animates with framer-motion (previously pinned in
  package.json but unused). New picks spring-slide into the front of
  the stack by subscribing to the ['heap-photo-ids', heapId] cache
  that the existing pick mutation already updates optimistically —
  no new event wiring. Unpicks run the exit transition and the
  remaining cards re-fan.
- Toasts are now subtle: glassy bg-surface/80 + backdrop-blur, thin
  2px left accent bar in the type color instead of a full tinted
  fill, smaller icons and text, tighter padding, truncation on
  overflow so they stay a single compact row. The colored alert
  block that was competing with the rest of the UI is gone.
- Card lives at the top of the sidebar specifically so the bottom-left
  toast stack can never occlude it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:35:06 +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
d6c667ae78 feat: persistent metadata panel, symmetric sidebar toggles, keyboard nav scroll
- Right sidebar stays open by default and shows an empty state when
  nothing is selected, instead of auto-hiding on deselect.
- Both sidebars now have a collapse button in their header and an
  expand button in the TopBar that only appears when collapsed, so
  each panel has a discoverable affordance in either state.
- Arrow-key navigation auto-scrolls the destination row into view
  with a ~35% peek margin, cueing the user that there's more content
  in the scroll direction.
- Fix: the width sentinel's measurement effect never installed its
  ResizeObserver when Timeline first rendered the loading state (ref
  was null, empty-dep effect didn't re-run), so containerWidth stuck
  at 0 and the grid fell back to 4 columns × 200px forever. Switched
  to a callback ref that attaches the observer the moment the
  sentinel actually mounts.
- KeyboardHints surface the Tab (library) and I (info) shortcuts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:20:22 +02:00
root
a4b1802657 fix: square timeline cells that fully fill each row
The grid was leaving horizontal space unused for two reasons:

1. Width measurement was based on parentRef.clientWidth - PADDING*2,
   which is fragile to padding/box-sizing/scrollbar mismatches and was
   off by enough pixels in practice to drop a column. Replace with a
   1px-tall normal-flow sentinel rendered inside the inner virtualizer
   wrapper at the exact horizontal extent rows render at. ResizeObserver
   on the sentinel gives the authoritative row width — no padding
   subtraction, no scrollbar guesswork.

2. The flex layout with explicit per-cell px widths accumulated floor()
   rounding error and let cells drift away from square. Switch to a CSS
   grid with fixed-px tracks (`repeat(cols, ${cellSize}px)` + matching
   `gridAutoRows`) so every track is exactly cellSize wide AND tall. By
   construction `cols × cellSize + (cols-1) × gap == measured width`,
   so the row fills edge-to-edge with cells that are guaranteed square.

PhotoThumbnail gains an opt-in `fill` prop the timeline uses to switch
its inline width/height to 100%, so the cell stretches to whatever the
parent grid track gives it. Heap sidebar / non-grid callers still get
explicit `size`-by-`size` square thumbnails as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:19:29 +02:00
root
ce25a4460e fix: never cache index.html so new bundle hashes are picked up
Vite content-hashes the JS/CSS bundle filenames, but the only thing
that tells the browser to fetch a new hash is a fresh index.html.
Without explicit no-cache headers nginx falls back to heuristic
caching, so users keep loading the old index.html → old bundle hash
until they hard-reload. Just hit this rolling out the timeline
pagination fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:42:16 +02:00
root
872be4e0cf fix: load full library + stretch grid to fill row width
Two unrelated bugs surfaced together because the symptom looked
similar ("missing photos in the grid"):

usePhotosQuery only ever fetched page 1 with per_page=500, so any
filter matching more than 500 photos silently truncated. With a
13k-photo library that meant the Timeline only showed ~3.8% of
matches and folders with many descendants looked broken. Walks all
pages now (capped at 200 = 100k photos as a sanity bound), keying
the React Query cache on the full filter set as before.

Timeline grid was leaving an unused horizontal strip on the right.
Two issues in the column math:
  - off-by-one: floor((W - 2P) / (T + G)) double-counts gaps. With N
    columns there are only N-1 inter-cell gaps, so the correct form
    is floor((W - 2P + G) / (T + G)). Reclaims a column whenever the
    remainder almost fits.
  - the floor remainder was discarded instead of distributed back
    into the cells. Treat THUMBNAIL_SIZE as a minimum and stretch
    each cell to (available - (cols-1)*gap) / cols so the row fills
    the container.

Also swap the resize listener for a ResizeObserver on the scroll
container so the grid re-flows when the sidebar collapses (window
resize alone misses that).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:22:06 +02:00
root
d27ec1af2e fix: prune orphaned folder rows alongside photo rows
prune_missing_photos() previously only deleted Photo rows whose files
were gone, leaving every folder row from the old library in the DB —
which made the sidebar tree wildly out of sync with the on-disk
structure (still showing /photos/2024/, /photos/2026/03/, etc. that
no longer exist).

Now also drops Folder rows whose path doesn't resolve under a mounted
source root, with the same defensive "skip if source root unmounted"
guard. The Settings orphan card surfaces both counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:50:32 +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
3df8add3b6 fix: stop scan crashing on duplicate file hashes
scan.py used scalar_one_or_none() to test whether any other photo
shared the same file_hash, but that helper raises MultipleResultsFound
the moment 2+ rows match — i.e. exactly the duplicate case it was
trying to flag. Every file beyond the second copy bombed out with
"Multiple rows were found when one or none was required" and was
left in the failed bucket. Replace with a COUNT(*) > 0 check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:14:40 +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
6fa00f4b37 style: unify thumbnail indicators on the primary blue palette
The badges on a thumbnail were a rainbow — yellow stars, blue check,
green heap pill, red discard, black duplicate — and read as five
unrelated palettes. Collapse to a single family.

PhotoThumbnail:
- Rating stars: bg-primary pill with white star icons (was yellow on
  a translucent dark backing).
- Heap basket / name chip: bg-primary (was bg-pick green).
- Duplicate badge: bg-primary (was bg-black/70).
- Discarded badge: bg-black/75 (kept neutral-dark, deliberately NOT
  blue, so "in this collection" and "trashed" never collapse into the
  same visual).
- All badges share the white outer ring + thicker icon stroke from
  the previous contrast pass.

HeapsPanel:
- Active heap row uses bg-primary/8 instead of bg-pick/10.
- Basket icon turns text-primary on the active row (was text-pick).
- "Active" pill uses bg-primary/25 + text-primary (was bg-pick/25 +
  text-pick).

The whole indicator family now reads as one consistent thing in the
brand blue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:30:35 +02:00
e49f64659e feat: surface active heap on photos + sidebar row, drop topbar pill
- PhotoThumbnail: the bottom-right basket badge now expands into a
  name chip when the active heap name is supplied. Truncated to a
  120px max-width so it doesn't eat the thumbnail.
- Timeline: read activeHeap.name from useActiveHeapMembersQuery and
  pipe it down to PhotoThumbnail.
- TopBar: drop the leftover heap pill next to "Mulimago" — the active
  state now lives where the user navigates to it (the heaps row).
- HeapsPanel: the active heap row gets a soft bg-pick/10 wash when
  not also filtered, the basket icon turns text-pick, and a small
  "ACTIVE" pill renders next to the name. Together they make it
  obvious which heap Pick / T target without needing the topbar pill.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:27:22 +02:00
9317730885 style: improve thumbnail indicator + selection contrast
- Bump primary from #3b6ed8 to #3b82f6 (Tailwind blue-500). Higher
  saturation reads better against both the dark surface and varied
  photo content. Contrast against bg-bg goes from ~5.7:1 to ~6.6:1.
- Selection ring: add ring-offset-2 ring-offset-bg so the bright blue
  has a dark gap separating it from the photo edge — pops on light
  and dark photos alike. Hover ring gets the same treatment.
- Selection check badge: white ring + shadow + thicker stroke so the
  badge is legible against any photo (was disappearing on bright
  scenes).
- Rating stars: wrap in a translucent dark pill with backdrop-blur so
  yellow stars don't vanish on yellow / sandy photos.
- Heap / duplicate / discarded badges: matching white ring + thicker
  icon stroke so they all read consistently and don't blend in.
- Timeline date headers: text-text instead of text-text-muted so the
  group labels actually pop above the grid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:22:07 +02:00
eb16564b84 fix: gate Timeline arrow keys to grid mode
PreviewView mounts its own arrow handlers via useHotkeys. The Timeline
also installed a window-level keydown listener for grid arrow nav, with
no viewMode check, so in preview mode BOTH handlers fired on every
arrow press and raced to call setActivePhoto. The grid handler walks
photoRows (grid cells) while preview walks the visible-order array,
and whichever store update landed last won, making preview nav land on
the wrong photo.

Telltale: Shift+arrow worked because PreviewView's plain useHotkeys
('left'/'right') doesn't match Shift+arrow, so only Timeline fired and
its visual-grid path got the right neighbor.

Fix: early-return Timeline's keyboard effect when viewMode !== 'grid'.
The listener stays attached to viewMode in the dep array so it
re-engages instantly on closePreview.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:14:19 +02:00
7bb03be51a fix: stable preview nav handlers via ref
react-hotkeys-hook can fire a stale closure when the callback dependency
array changes between renders, causing arrow nav to read an old photos
array (e.g. the empty initial render before visiblePhotoIds was applied)
and land on the wrong photo or no-op entirely.

Move the latest photos / activePhotoId into a navRef updated on every
render. The goPrev / goNext callbacks become stable (their useCallback
deps shrink to just setActivePhoto) and read the freshest values from
the ref at fire time. useHotkeys no longer has to re-bind on every
render — the handlers can capture the ref once.

The visible-order array still drives navigation; this just removes the
re-bind race that was making it look like nav was ignoring it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:07:46 +02:00
123c60ed2c fix: visible-order range selection + preview-after-Space sequence
Two related bugs around visible vs API order.

1. Multi-select range selection (Shift+Click, Shift+Arrow):
- The previous selectRange walked the API photos array and only
  ADDED to the existing selection, never replacing or shrinking. So
  Shift+clicking to the left often "did nothing" (already-selected
  ids skipped) and the selection never matched the user's intended
  range.
- Replace with a store action that walks visiblePhotoIds (the visual
  row-major sequence Timeline already publishes), de-dupes ids
  (tag-grouped views can repeat photos), and REPLACES the selection.
- Track the range anchor as rangeStartId (a photo id) instead of an
  index so it survives filter changes and works correctly when API
  index != visual position.
- Drop the now-redundant lastSelectedIndex / globalIndex plumbing
  from selectPhoto / togglePhotoSelection — call sites simplify to
  pass just the photo id.

2. Preview navigation after pressing Space:
- The Space hotkey path called openPreview(id) without a sequence
  and relied on the store's fallback to whatever Timeline most
  recently published. Make it explicit: read visiblePhotoIds from
  the store snapshot at fire time and pass it through. Same effect
  in the happy case but eliminates any subtle publisher timing
  question.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:04:18 +02:00
e2ee9b691c fix: pass visible sequence to openPreview from the click site
Previously the visible photo order was published only via a passive
useEffect on Timeline, which had a timing race: arrow nav in preview
could read a stale or empty sequence and fall back to the raw API
order, breaking visual order navigation in tag mode and after
filter changes.

Fix: openPreview now accepts an optional visibleSequence parameter,
and Timeline's onDoubleClick passes the freshly-computed flat
sequence directly. The store action adopts that sequence as the
authoritative visiblePhotoIds for the preview session, falling back
to the most-recently-published one for paths that don't have a click
site (e.g. the global Space hotkey).

The Timeline still publishes via useEffect for the Space-hotkey
fallback path, but the click path no longer depends on it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:52:42 +02:00
57f510ad3d fix: keep keyboard hints on a single line
Add whitespace-nowrap to the hints pill container plus the action and
selection-count spans so labels like "Pick → heap" and "1 selected"
no longer break across rows. The pill is an absolute overlay with no
width constraint, so growing horizontally is fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:46:27 +02:00
26424469db feat: center hints over the timeline + add hubris footer
- KeyboardHints: switch from fixed positioning to absolute, mounted
  inside the main content column. The column is now relative-positioned
  so the hints overlay centers against the timeline area instead of the
  raw viewport (which was off-center because of the sidebars).
- AppFooter: tiny "Built with hubris • <YEAR in roman>" pinned to the
  bottom-right corner of the main column. Year is computed at render
  time and converted via a small toRoman helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:34:18 +02:00
ec4a14976d style: switch primary accent from teal to deep royal blue
Replace #4f98a3 (desaturated teal) with #3b6ed8 (deep royal blue) as
the app's primary accent. Affects every text-primary, bg-primary,
ring-primary, border-primary class — selection rings, active sidebar
rows, the active filter pill background, the loupe info button when
open, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:30:46 +02:00
e7d62c29e1 feat: change empty state 2026-04-08 21:27:56 +02:00
8e00dd40f0 chore: rename app from Mulita to Mulimago
User-visible string change only — TopBar header + browser tab title
+ logo alt text. Container names, internal package names, and
directories keep their existing identifiers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:27:07 +02:00
8db4242503 fix: clear-all sits next to filters with a borderless style
Move the Clear-all button inside the pills flex container so it shares
the cluster's gap and reads as the rightmost item of the filter group
instead of floating between filters and search. Drop the bordered
pill styling for a flat text-button (underline on hover) so it doesn't
look like another active filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:24:52 +02:00
dea19e5c23 fix: filter bar layout — pills left, search right
Previous layout was [search] [centered pills] [clear-all]. Flip to
[pills left-aligned] [clear-all] [search right]. Pills get a flex-1
slot on the left so they fill the available space and overflow-x-
auto kicks in when they don't fit. Clear-all only renders when any
filter is active and sits between the pills and the search input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:23:04 +02:00
5a0f9ff592 fix: heap convert dialog supports nested subfolders + visiblePhotoIds loop guard
- HeapConvertDialog: switch the target picker from sourceFolders.list
  (top-level source roots only) to useFolderTreeQuery, flattened
  depth-first into a list with depth info. Each option is indented
  with non-breaking spaces so nested subfolders read as a tree in
  the native dropdown. Backend already accepts any Folder id, so no
  server change needed.
- photoStore.setVisiblePhotoIds: short-circuit when the new id list
  matches the existing one element-for-element. Avoids feedback loops
  if a publisher fires from an effect on a render where the contents
  haven't actually changed (which was triggering React error #185).
- Timeline: pull setVisiblePhotoIds via a focused selector instead of
  the wholesale destructure so the publisher subscription doesn't
  re-render Timeline on unrelated photo store changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:17:32 +02:00
a56062d353 fix: preview navigation walks the timeline's visible order
Previously the preview view walked the raw API photos array for arrow
navigation and the filmstrip. In tag-grouped mode (and any future
layout where the visible grid order diverges from the API sort) that
diverged from the order the user actually saw — they'd hit ← / → and
land on a photo that wasn't adjacent in the grid.

Fix: Timeline publishes its flat visible-order id sequence into the
photo store as visiblePhotoIds whenever its layout items change
(including duplicates from tag buckets, which is what the user wants
in tag mode — landing on a photo's second appearance in the next
bucket is the right behavior). PreviewView resolves that sequence
back to Photo objects via the rawPhotos map and uses the result for
both arrow nav and the filmstrip. Falls back to the raw photos list
when the sequence isn't populated yet.

Also clean up the lingering hardcoded http://localhost:8001 in
usePhotosQuery — switched to the shared axios instance with the
relative /api/v1 baseURL so the hook works cross-machine through the
nginx / vite proxy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:13:08 +02:00
7a0f738aa8 fix: keep Rating + Tags pills in their sections, only hide Flag in Discarded
Rated and Tags sections still benefit from their respective pill —
Rating because the user can refine the section's ratingMin >= 1 to a
higher floor, Tags because they can intersect the tag-grouped view
with a specific tag id list. Flag in Discarded is the only pill where
the section locks the only useful value, so it stays hidden there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:07:26 +02:00
d7f953d0a9 fix: hide section-locked filter pills in their own sections
Each section's preset locks one filter dimension that defines the
section: Rated → ratingMin, Discarded → flag, Tags → groupBy=tag.
Showing the matching pill in the toolbar while you're inside that
section is either redundant (it's already on) or actively breaks the
view (toggling it would either become a no-op or filter the section
into one bucket).

Hide the corresponding pill in each section: Rating in Rated, Flag in
Discarded, Tags in Tags. The user navigates away to a different
section to change the locked dimension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:04:45 +02:00
30e0900e49 fix: lock filter bar height + freeze pill positions
- FilterPill: fixed h-7 + py-0 on the button so neither the X clear icon
  nor the chevron can stretch the pill vertically when active state
  swaps them in. The chevron is now wrapped in the same h-4 w-4 slot as
  the clear X so swapping doesn't change footprint horizontally either.
- FilterBar: fixed h-11 on the bar itself so any future per-pill drift
  can't grow the row.
- Clear-all: wrapped in a fixed w-56 right slot that mirrors the search
  input on the left. The pill cluster sits in the centered flex-1
  middle slot, so it stays perfectly centered whether or not Clear-all
  is rendered. The button itself is right-aligned within the slot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:01:01 +02:00
c01c3b02ce chore: use the Mulita logo as the browser favicon
Drop the leftover Vite default and point the favicon + apple-touch-icon
at a copy of the existing muli-logo.png served from /public. Also
trim the page title to "Mulita" and add a dark theme-color meta tag
so mobile browsers paint the chrome to match the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:58:13 +02:00
4e5b2cabf6 fix: align sidebar counts in a single right column
The previous count-alignment fix used invisible group-hover:visible for
hover-only buttons, but invisible still reserves layout space. Folder
rows had a permanent kebab slot that non-folder rows didn't, and active
heap rows had a Target indicator before the count — both shifted their
counts left of the rest. The result was visually misaligned counts.

- LeftSidebar folder kebab + HeapsPanel kebab/set-active: switch to
  hidden group-hover:block so the slot occupies zero width in the
  resting state. Counts now sit at the same right edge across folder,
  non-folder, and heap rows.
- HeapsPanel: drop the standalone Target indicator from active heap
  rows. Active state is signaled by the bold name (font-semibold)
  already, and removing the indicator lets the heap count column line
  up with everything else.
- Both kebab wrappers also use hidden group-hover:block on the wrapper
  div so the menu trigger truly takes 0 width when not hovered.

On hover the kebab appears to the right of the count and pushes it
slightly left, as the user requested ("on hover we can push them to
make space for the burger").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:57:11 +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
fc63b1f69d config: env-driven CORS, ports, log level, timezone
The CORS allowed-origins list, host port mappings, log level, container
timezone, and worker concurrency are now all driven by environment
variables with sane defaults. Same-origin access through the nginx
proxy keeps working with no config; direct cross-origin backend
access can be locked down via ALLOWED_ORIGINS.

- backend/config: ALLOWED_ORIGINS env (comma-separated, "*" for any)
  exposed via settings.cors_origins. LOG_LEVEL too.
- backend/main: build the CORS middleware from settings.cors_origins,
  auto-disable allow_credentials when origins is wildcard (CORS spec
  forbids credentials + "*").
- docker-compose: parameterize FRONTEND_PORT, BACKEND_PORT, REDIS_PORT,
  CELERYD_CONCURRENCY, LOG_LEVEL, and TZ via ${VAR:-default} so each
  has a working fallback if the .env entry is missing.
- .env.example: new template documenting every knob with examples.
- .env: pruned to only the values that diverge from .env.example;
  removed dead VITE_API_URL.
- README: configuration knobs table + "accessing from another machine"
  section explaining the same-origin proxy story.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:31:59 +02:00
ac5b18b60c fix: cross-machine access + center filter bar + floating hints
- api.ts: switch baseURL from http://localhost:8001/api/v1 to relative
  /api/v1. Both nginx (prod) and vite (dev) already proxy /api/ to the
  backend, so requests become same-origin and the app works from any
  host (LAN IP, reverse proxy, another machine) with no CORS dance.
- backend CORS: open to "*" as a fallback for the rare direct-hit case;
  the normal flow is same-origin via the proxy and never touches CORS.
- App layout: move FilterBar and DiscardActionBar inside the main
  content column (right of the left sidebar) so the filter row no
  longer bleeds across the sidebar.
- FilterBar: justify-center the pills so they sit centered above the
  timeline. Clear-all uses ml-2 instead of ml-auto.
- KeyboardHints: convert to a floating, glassy pill pinned bottom-
  center (fixed positioning + backdrop-blur + ring) instead of a flat
  toolbar row. Removed from the column layout — now mounted as an
  overlay sibling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:28:04 +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
07b9660e92 feat: undo for destructive photo actions
Add a global last-action stack with toast-based "Undo" buttons and a
Cmd/Ctrl+Z hotkey for the destructive photo operations.

Reversible:
- X (discard) → bulkRestore
- U (restore) → bulkDiscard
- Drag-onto-Discarded → bulkRestore
- Drag-onto-folder (move) → move back to per-photo source folders. The
  source folder ids are snapshotted from the photos cache before the
  move runs, then grouped so multi-source moves restore correctly.
- Restore button in the discard action bar → bulkDiscard

Toast gains an optional action button (label + onClick); toasts with an
action stay visible longer so the user has time to click. The undo
store caps at 20 entries; failed undo re-pushes the entry so the user
can try again.

Not reversible (call out, document later): rating, color label, copy,
permanent delete from trash, tag changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:08:58 +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
3a03a56db2 feat: photo metadata panel in preview view
Extract the single-photo body of RightSidebar into a reusable PhotoInfoPanel
(rating / color / flag / filename / title / notes / tags / EXIF) and mount
it inside PreviewView as a toggleable right-side overlay so the user can
rate, tag, and read EXIF without leaving the loupe.

- New PhotoInfoPanel: self-contained, owns its own queries and mutations,
  takes a single photoId. darkTheme prop reserved for future use.
- RightSidebar: thinned down — delegates the single-select case to
  PhotoInfoPanel, keeps its own slim bulk-action panel for multi-select.
- PreviewView: I toggles the panel; new top-right Info button mirrors it.
- useKeyboardShortcuts: gate the global I (right-sidebar toggle) to grid
  mode so it doesn't double-fire alongside the preview-scoped handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:01:25 +02:00
1c428dda8b fix: portal FilterPill popover so it escapes overflow clipping
The FilterBar uses overflow-x-auto for horizontal scroll, which forces
overflow-y to auto as well — that was clipping the absolutely-positioned
pill popovers below the bar. Render the popover into document.body via a
portal with fixed coordinates derived from getBoundingClientRect(), and
clamp the left edge so right-most pills don't push the popover off-screen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:53:04 +02:00
2f1e9033ae refactor: compact pill-based filter toolbar
Merge the toggleable multi-line FilterBar and the separate ActiveFilterChips
strip into a single always-visible row of pills. Each filter category is a
pill that opens a small popover with its underlying control; when active, the
pill shows its current value inline (so the chips strip is redundant).

- New FilterPill primitive: outside-click + Escape to close, optional inline
  X to clear without opening the popover.
- FilterBar rebuilt out of pills for Date/Type/Rating/Color/Flag/Tags/Sort,
  with a Clear-all pill on the right when any filter is active.
- Drop filterBarOpen from filterStore, the SlidersHorizontal toggle from
  TopBar, the \\ shortcut from useKeyboardShortcuts, and the matching hint
  from KeyboardHints — the bar is always visible now.
- Delete ActiveFilterChips; its information lives inside the pills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:49:14 +02:00
522228fb79 refactor: drop LeftSidebar header in favor of group rows
The "Views" header + MoreHorizontal kebab were vestigial — the Views/Folders
group rows already label themselves, and the kebab was a no-op. Swap the
group icons (Layers2 for Views, HardDrive for Folders) so the visual
hierarchy stays clear without the header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:48:45 +02:00
5e10b12b13 fix: arrow key navigation matches the visual grid
The Timeline arrow keys moved by currentIndex ± columns in the FLAT
photos array, but with date / tag grouping the rendered grid has
half-full last rows for each group, so flat-index nav routinely
landed in the wrong cell — and tag grouping (where one photo can
appear in multiple groups) made it incoherent.

Fix: navigate the actual visual grid the user sees.

- New photoRows = items.filter(type='row') in visual order. The
  buildItems pipeline already chunks photos into row items of
  [1..columns] cells per group; this is exactly the rendered layout.
- findActiveCell() walks photoRows looking for the activePhotoId
  and returns its (rowIndex, colIndex), or null if it isn't on
  screen. First-occurrence wins, which matches user intuition in
  the tag-grouped view.
- New move(dr, dc) helper:
    Left/Right: walk col, wrap across row boundaries (so going Right
    off the end of a half-full row jumps to the next group's first
    row). Clamps at the very first/last cell.
    Up/Down: change row, then clamp the column to the destination
    row's actual width — moving down into a 2-cell row from col 3
    lands on col 1, not nothing.
- The four arrow handlers all funnel through move(); shift-arrow
  still calls selectRange with the destination cell's globalIndex
  so range selection works the same as a shift-click on that cell.
- Headers are skipped automatically because they were never in
  photoRows. Edge cells, end-of-group, single-row groups, and
  tag-repeated photos all behave consistently.

Pulled activePhotoId out of usePhotoStore (was already in the store
but the Timeline component wasn't reading it). Effect deps updated
to invalidate the listener whenever the visible grid changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:25:16 +02:00
8fd8bfe3de feat: bulk rating / color / discard / pick from selection
Culling actions used to operate on a single photo (the activePhotoId)
even when many were selected — pressing 5 with ten thumbnails high-
lighted only rated one. Same for the RightSidebar buttons, which
weren't even visible in multi-select mode. Lightroom semantics: every
culling action applies to the whole selection.

Fix
- Three new bulk helpers in services/api.ts:
    photos.bulkSetRating(ids, rating)
    photos.bulkSetColor(ids, color | null)
    (existing photos.bulkDiscard / bulkRestore reused for X / U)
  All matching the backend BulkAction { ids, action, value } shape
  the /photos/bulk endpoint already accepts.

useKeyboardShortcuts
- New cullTargets() helper: selectedPhotos if non-empty, else
  activePhotoId in a singleton, else empty.
- updateActive() now branches on cullTargets().length:
    1 → existing PATCH /photos/{id} path (single-photo).
    2+ → fans out to the right bulk endpoint per field. rating goes
         to bulkSetRating, color_label to bulkSetColor, is_discarded
         to bulkDiscard / bulkRestore.
- 1-5 / 0 / X / U / 6-9 shortcuts now Just Work on multi-select
  without further changes — they all funnel through updateActive.

RightSidebar
- Restructured the Quick Actions block: filename / title / notes are
  hidden in multi-select (they only make sense for one photo); but
  rating / color / flag controls are now always visible when at
  least one photo is selected. A small "Rating, color, and flag
  apply to all N selected" hint shows in multi mode.
- New applyRating / setColor / applyDiscard helpers fan out to the
  bulk endpoints when selectedPhotos.length > 1, otherwise hit the
  per-photo PATCH path. The displayed value still reflects the
  active photo (last clicked) so the user has a visual anchor —
  matches Lightroom's "focused vs selected" model.
- Pick/Heap-toggle button is now selection-aware too: heapMutation
  takes ids[], the click handler reads selectedPhotos, and the
  add-vs-remove decision uses "every selected is a member" exactly
  like the P keyboard shortcut. Optimistic membership cache update
  also flips the basket badge across all selected thumbnails
  instantly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:19:53 +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
b4a2241bd9 feat: per-section filter memory
Filters were global — switching from "Discarded" to a folder kept the
discarded flag, switching from a heap to All Photos kept the heap
filter, etc. Confusing because the user couldn't tell what state any
section would be in until they got there.

Now each "section" remembers its own filter state independently. The
in-memory map is keyed by section id ('all-photos', 'rated',
'discarded', 'duplicates', 'tags', 'folder-{id}', 'heap-{id}'), and
navigating saves the current section's state under its id and
restores the destination's. Sections you've never visited start with
their intrinsic preset on top of INITIAL_FILTERS.

filterStore additions
- currentSection: string (default 'all-photos')
- sectionFilters: Record<sectionId, FilterState> — in-memory snapshots
- sectionPresets: Record<sectionId, Partial<FilterState>> — the
  intrinsic filter that defines each section, used by clearAll
- navigateToSection(id, presetOverrides):
    1. snapshot the current FilterState slice into sectionFilters[
       currentSection]
    2. record presetOverrides in sectionPresets[id]
    3. set currentSection = id
    4. load sectionFilters[id] if a saved snapshot exists, otherwise
       apply presetOverrides on top of INITIAL_FILTERS
- clearAll: now resets the CURRENT section to its preset rather than
  jumping to all-photos. The user explicitly clicks All Photos to
  navigate.
- snapshotFilters() helper extracts the FilterState slice cleanly so
  control fields (filterBarOpen, the maps themselves) don't leak
  into per-section state.

URL sync
- writeUrl serialises currentSection as ?section=… (omitted for the
  default 'all-photos').
- parseUrl reads it back into currentSection on hydrate. Per-section
  memory is in-memory only; reload restores the current view but
  not the other sections' saved states (acceptable for MVP).

LeftSidebar
- applyLibraryNode now dispatches navigateToSection per node, with
  the appropriate preset:
    all-photos → {}
    rated      → { ratingMin: 1 }
    discarded  → { flag: 'discarded' }
    duplicates → { duplicates: true }
    tags       → { groupBy: 'tag' }
    folder-X   → { folderId: X }
- isItemActive collapses to a single check against currentSection
  for both library nodes and folder rows. Dropped the old
  selectedItem local state and the per-field active probes; they
  were doing the same job in a more fragile way.

HeapsPanel
- Heap row click → navigateToSection(`heap-${id}`, { heapId: id })
- isFiltered uses currentSection instead of filterStore.heapId
- Deleting the currently-viewed heap navigates back to all-photos
  via navigateToSection (was setFilterHeapId(null), which now lives
  in the section model).

User flow:
1. Click Discarded → seeing discarded photos.
2. Open FilterBar, set Rating ≥ 3 — discarded section now has rating.
3. Click Library "Library" folder → no rating filter, just library
   contents.
4. Open FilterBar, set media type Photo only — folder section now
   has that.
5. Click Discarded again → restored to discarded + rating ≥ 3.
6. Click Library folder again → restored to library + photo only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:07:16 +02:00
db37be902e fix: disable folder watcher on startup to unblock scan_folder
Diagnosis: every backend restart was dispatching watch_folders.delay()
unconditionally. watch_folders is an infinite-loop celery task
(for changes in watch(*paths)). With CELERYD_CONCURRENCY=4 and several
restarts during dev, all four worker slots ended up pinned by stale
watch_folders instances, leaving zero workers free for scan_folder.
The result: clicking "Scan all folders" successfully queued a task
that then sat in the queue forever, the new /photos/sub folder was
never walked, and the user's newly added photo never appeared.

The watcher was only opportunistically useful and the user already
triggers scans manually. Disabling it removes the foot-gun. Re-
enabling needs:
  - a Redis lock so only one watcher runs at a time
  - or a dedicated long-running container with concurrency=1
  - or a celery beat schedule with a singleton flag

Until then, manual scans work. Cleared the backlog by wiping the
redis broker volume so the stale watch_folders tasks are gone.

Verified: post-fix, scan_folder runs in 0.12s and reports
"Processed 7/7 files. Errors: 0", picking up the previously missing
/photos/sub/Samuel_Colman... file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:01:19 +02:00
6917e618e5 fix: parent folders clickable; refresh photos when scan completes
Two related sidebar UX bugs.

1. Parent folders weren't clickable
   renderTreeItem's onClick called toggleExpanded(item.id) for any
   row with children — so a parent folder only expanded/collapsed,
   never applied its filter. Restructured: folder rows always call
   applyLibraryNode (which the photos endpoint already expands to
   include descendants), and the chevron remains a separate
   stopPropagation button for expansion. Other group headers
   (Library, Folders, Tags) still toggle expansion on row click
   since they have no associated filter.

   Result: clicking any folder at any depth filters the timeline
   to that folder + every descendant, matching the Lightroom
   model the user expects.

2. New files not appearing after Scan all folders
   scanLibraryMutation.onSettled invalidated ['photos'] when the
   trigger returned, but POST /library/scan just queues the celery
   task and returns immediately. By the time the worker finishes
   walking the directory and inserting new rows, the photos query
   has already refetched (with no new data) and is sitting on a
   30-second staleTime — so newly-indexed photos stayed invisible
   until the next manual refetch.

   Fix: ScanProgress already polls /library/scan/status. Track the
   previous is_scanning value via a ref; when it transitions from
   true → false, invalidate ['photos'], ['folders'], ['folders',
   'tree'], ['heaps'], and ['tags']. That's the actual moment new
   data is available, regardless of how the scan was triggered
   (button, watcher, startup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:54:31 +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
2d37fba211 feat: Tags entry in the Library sidebar
Adds an expandable Tags node alongside All Photos / Rated /
Duplicates / Discarded. The children are populated dynamically from
useTagsQuery — one row per tag, showing the tag name and its photo
count badge. Click a tag row to filter the timeline to just that
tag (single-tag), with the active highlight following the filter
store.

Multi-tag filtering still lives in the FilterBar; the sidebar entry
is the quick "show me everything in this tag" affordance.

Implementation
- New 'tags' library tree node with children: allTags.map(...)
- 'tag-{id}' click handler in applyLibraryNode → clearAll() +
  setTagIds([id])
- isItemActive recognises a tag row as selected only when the
  filter store has exactly that single tag id, so combining it with
  multi-tag filter mode in the FilterBar doesn't leave a stale
  highlight.
- Tags section is collapsed by default like other library nodes; no
  effect when there are no tags yet (children list is empty).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:39:33 +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
a8750afef0 feat: cleaner TopBar + live scan progress wired end-to-end
Two related polish items.

1. Drop dead TopBar buttons
   - Removed the hamburger menu (Tab already toggles the sidebar),
     the grid/list view-mode toggle (only Grid was ever
     implemented), and the FolderOpen / Upload / Settings action
     icons (no features behind them).
   - TopBar is now: logo + active heap pill | search | filter
     toggle. Removed the now-unused Grid/List/Menu/FolderOpen/
     Upload/Settings icon imports and the dead viewMode local
     state.

2. Wire live scan progress
   - The frontend ScanProgress widget was already polling
     /api/v1/library/scan/status, but the worker never wrote the
     Redis keys that endpoint reads — it only updated celery's
     internal task state. So the progress UI was permanently idle.
   - Worker now writes scan:active / scan:current_folder /
     scan:processed_files / scan:total_files / scan:errors at
     every meaningful step. _get_redis() returns None on failure
     so a Redis outage degrades gracefully (scan still runs,
     progress just doesn't show).
   - Pre-walk computes total_files upfront — without it the
     progress bar jumped every time os.walk discovered a new
     subfolder because the running total was being updated as it
     went.
   - Errors are RPUSHed to a capped list (MAX_ERROR_ENTRIES=50)
     so a noisy scan can't blow up Redis.
   - finally: clause guarantees scan:active flips to false even
     on a crash, so the UI never sticks at "scanning" forever.
   - scan_all_source_roots clears scan:errors and resets counters
     before queuing the per-root tasks, so each top-level scan
     starts with a clean slate.

   Two latent bugs caught and fixed in passing:
   - watch_folders was still reading settings.source_roots which
     no longer exists since we moved source roots to the DB. Now
     it loads them from the DB via a synchronous one-shot async
     wrapper at task startup.
   - _scan_all_source_roots_async was missing entirely after the
     last refactor — defined inline now, reads active source
     roots from the DB and dispatches scan_folder per row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:54:11 +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
a0c41e38d3 revert: drop By Date sidebar node and supporting code
Tried it, didn't add value beyond what the date-grouped timeline
already gives. The grouped, sticky-headered timeline (which kicks
in by default whenever sortBy is taken_at) is the better
affordance for date navigation — duplicating that as a sidebar
drilldown was just clutter.

Removes the full stack:
- LeftSidebar: by-date tree node, byDateChildren computation,
  date-year-/date-month- handlers in applyLibraryNode, isItemActive
  branches that matched a date-range filter, the now-unused
  setDateFrom/setDateTo/filterDateFrom/filterDateTo selectors, and
  the Calendar icon import.
- frontend/src/hooks/useDateBucketsQuery.ts deleted entirely.
- api.ts: library.dateBuckets helper and DateBucketYear/Month types.
- backend/app/routers/library.py: GET /library/date_buckets endpoint
  and its strftime aggregation query.

The dateFrom/dateTo filter state stays in filterStore — the
FilterBar still uses it for the "Date" range inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:19:59 +02:00
7d33e1688a feat: By Date sidebar — year/month drilldown navigation
The "By Date" library node was decorative. Now it's a real
hierarchical navigator: expand to see year buckets (with photo
counts), expand a year to see its months, click any year or
month to filter the timeline to that date range.

Backend
- New GET /library/date_buckets aggregates non-discarded photos
  by year+month from Photo.taken_at via SQLite strftime, returning
  [{ year, count, months: [{ month, count }] }] sorted newest-
  first. NULL taken_at rows are excluded.

Frontend
- New library.dateBuckets() helper + DateBucketYear / Month types.
- New hooks/useDateBucketsQuery.ts with a 60s staleTime.
- LeftSidebar builds the By Date subtree dynamically from the
  query: each year is a tree node with month children. Year nodes
  use the Calendar icon, months render as their full English name.
- applyLibraryNode handles two new id prefixes:
  'date-year-{year}'  → setDateFrom YYYY-01-01, setDateTo YYYY-12-31
  'date-month-{Y}-{M}'→ setDateFrom YYYY-MM-01, setDateTo YYYY-MM-LL
  where LL is the last day of the month (computed via Date trick
  new Date(year, month, 0).getDate() — uses month-day=0 to roll
  back into the previous month's last day).
- isItemActive recognises when the current dateFrom/dateTo matches
  a year or month node so the sidebar selection highlight stays
  in sync with the filter store (also when filters are set
  externally via the filter bar or URL hydration).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:14:57 +02:00
16481730b7 feat: drag photos onto a folder to move them
Bulk-move via drag-and-drop. Drop a photo (or multi-selection) on
any folder row in the LeftSidebar and the files move on disk +
photo.folder_id updates atomically.

Backend
- New POST /photos/move accepting { photo_ids, target_id }. The
  target_id can be either a Folder id OR a SourceRoot id (the
  sidebar exposes source roots today, so the same drag target
  needs to resolve either).
- Resolves source roots to their on-disk path and looks up / creates
  the canonical Folder row via the existing scan get_or_create_folder
  helper, so dedupe + path normalization stay consistent with the
  scanner.
- Per-photo loop with shutil.move; per-file failures (target name
  collision, missing source, OS error) are collected into a
  structured `errors` array and don't abort the batch.
- Skips photos that are already in the target folder so re-drops
  are a no-op.

Frontend
- New photos.move(ids, targetId) helper in api.ts.
- LeftSidebar grows a moveDropMutation alongside the existing
  discard one. handleDrop dispatches by id prefix:
  'discarded' → discard, 'folder-{id}' → move.
- Folder rows now report acceptsDrop and get the same drag-over
  highlight as heap drops, in primary tint instead of reject.
- onSuccess invalidates both the photos query and the folders
  query so the new folder counts in the sidebar refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:13:04 +02:00
066acb64ec feat: drag photos onto Discarded sidebar node to discard them
Same drag pattern as the heap drop, but the target is the Discarded
library node. The dropped photos go to is_discarded=true via a
single bulk request.

Backend: the /photos/bulk endpoint already had a 'discard' action
branch; the missing piece was a frontend client that sent the right
shape. The previous photos.bulkUpdate sent
{ photo_ids, discard: true } against a backend that wanted
{ ids, action } — silently broken since day one. Replaced with two
narrow helpers that match BulkAction exactly: photos.bulkDiscard(ids)
and photos.bulkRestore(ids).

Frontend: LeftSidebar grows a small dnd state machine — dropTargetId
for the hovered row, isDropTarget(id) for which library nodes accept
drops, handleDrop(id, ids) for the dispatch. Today only the
'discarded' node is wired; folder rows for bulk move come next.
Drop highlight uses the reject ring/tint to match the destructive
nature of the action. Toast confirms; photos query is invalidated
so the timeline immediately drops them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:11:23 +02:00
485b60ff20 feat: drag photos onto a heap row to add them
Lightroom-style direct manipulation: pick up a photo (or a multi-
selection) and drop it on a heap to add it. Complements the P
shortcut without replacing it.

PhotoThumbnail
- Becomes draggable. onDragStart reads the current selection from
  the photo store at fire time: if the dragged photo is part of the
  selection, the payload is the whole selection; otherwise it's
  just that one photo. Matches Finder semantics.
- Payload uses a custom MIME (application/x-mulita-photos) so the
  drop target can recognise our drags vs. arbitrary file drags from
  the OS. Also sets text/plain so dropping outside the app shows a
  sensible "N photos" string.

HeapsPanel
- Each heap row is now a drop target. onDragOver previews the drop
  effect and highlights the row with a primary ring and a faint
  background tint. onDragLeave only clears the highlight if the
  cursor actually left the row (not just moved over a child).
- New dropMutation handles the drop: optimistic membership cache
  update so the basket affordance flips immediately, rollback on
  error from a captured `previous`, success toast naming the heap
  and the count of newly-added photos, onSettled invalidation of
  heaps + heap-photo-ids + photos so server truth re-syncs.

PhotoThumbnail's title attribute now mentions the drag affordance
alongside click/double-click/shift+click/ctrl+click hints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:59:29 +02:00
9729391dcc chore(docker): mount ~/Pictures read-write so file ops work; document
The first phase-11 file op (inline rename) returns EROFS today
because docker-compose mounts ~/Pictures read-only by default.
Lightroom-style file operations (rename, move, discard-pile empty)
all need to mutate the filesystem, so the right default is :rw.

Flips both the backend and worker mounts to :rw with an inline
comment explaining the trade-off, and adds a "Photo directory
mounts and permissions" section to the README that:
- States the default is now :rw
- Explains exactly which endpoints fail under :ro (rename, empty
  discard pile, future move/copy)
- Notes the implication: Mulita has full write access to whatever
  host directory ends up at /host/Pictures, same trust model as
  Lightroom's catalog folder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:56:27 +02:00
7c003bc92e feat: editable filename in RightSidebar (renames file on disk)
The first phase-11 file operation. Lightroom-style inline rename
of a single photo, in place, in its current directory.

Backend (PATCH /photos/{id})
- PhotoUpdate schema accepts an optional `filename`.
- When set, the handler validates: non-empty, no path separators,
  no `..`/`.`, target name doesn't already exist in the directory,
  source file exists on disk.
- os.renames the file inside its current directory, then updates
  photo.filename + photo.filepath atomically. The DB only changes
  after a successful rename — a filesystem failure leaves the
  rest of the row untouched.
- Other PhotoUpdate fields still apply afterwards in the same
  request.

Frontend (RightSidebar)
- Filename is now an editable monospace input above the Title
  input. Same draft + commit pattern as title/notes (local draft,
  resync on photo.id change, on-blur or Enter commits).
- Esc reverts to the server value.
- Client-side validation mirrors the backend (rejects path
  separators and dot-segments) and shows a toast on backend
  errors with the FastAPI detail message, then rolls the draft
  back so the input matches the still-on-disk filename.
- Removed the old read-only Filename Field from the Basic Info
  section to avoid showing the same value twice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:48:31 +02:00
cf7c72d437 fix: deduplicate scanner-created source_roots and folders
Two related fixes:

1. Prevention — scanner now normalizes paths before lookup/insert
   in get_or_create_source_root and get_or_create_folder. Trailing
   slashes, redundant separators, and `.` segments all collapse to
   the same row. _normalize_path uses os.path.normpath; symlinks
   are intentionally NOT resolved so mount paths stay intact for
   cross-machine portability.

2. Cleanup — new app/services/cleanup.py runs on backend startup
   (idempotent) and merges any pre-existing duplicates left over
   from older scanner versions:
   - Groups source_roots by normalized path. Picks the canonical
     row (preferring one with a non-empty name and the earliest
     added_at), re-points child Folder rows via UPDATE, and
     deletes the duplicates.
   - Same for folders, with photo_count as the tiebreaker. Photos
     get re-pointed to the canonical folder via UPDATE.
   - Recomputes folder.photo_count from the actual non-discarded
     photo membership so the sidebar count matches reality.

Wired into main.py's lifespan handler. On the dev DB this merged
the empty-name "/host/Pictures/MulitaTest/" duplicate that was
showing up alongside the canonical MulitaTest source root.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:43:53 +02:00
7dcfa8f30d fix: photos folder_id filter accepts source root ids too
GET /folders returns SourceRoot rows (the top-level scan paths
shown in the LeftSidebar tree), but photos.folder_id points to
a Folder row (a directory under a source root), and the photos
list endpoint was matching Photo.folder_id == folder_id literally.
Result: clicking "MulitaTest" in the sidebar sent the source root
id, which never matched any photo, so the timeline went empty
even though the photo_count badge showed 5.

Fix: when the folder_id param matches a SourceRoot, expand it to
every child Folder.id under that root and use IN. Falls back to
the literal match for actual folder ids. If a source root has no
child folder rows yet, returns no photos (rather than the whole
library) so a half-scanned root doesn't accidentally show
everything.

The longer-term cleanup is to deduplicate the source_root /
folder rows the scanner is creating on each rescan, but this
makes the navigation work today.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:33 +02:00
66ffd94c48 feat: folder navigation from sidebar
Folders in the LeftSidebar were decorative — clicking one did
nothing. Now they actually filter the timeline.

filterStore: new folderId field, setFolderId, hasActiveFilters check,
filtersToParams sends folder_id to the backend (the param was already
declared and applied server-side, just nothing was setting it).
useFilterUrlSync round-trips ?folder_id= so the filter persists in
the URL. usePhotosQuery threads it through.

LeftSidebar:
- Clicking a folder row calls clearAllFilters() then setFolderId(id)
  so the user lands cleanly on that folder.
- Library virtual nodes (All Photos, Rated, Discarded) clear the
  folder filter as part of their normal action.
- The active-row visual highlight is now derived from the filter
  store: a folder row is selected when filterStore.folderId matches
  it, "All Photos" is selected when no folder is set. Keeps the
  sidebar in sync if filters change externally (URL hydrate, the
  ActiveFilterChips X button, FilterBar Clear all).

ActiveFilterChips: shows "Folder: {name}" and "Heap: {name}" chips,
looking up the names from the folders / heaps queries (lazy-enabled
only when the corresponding filter is set). Clicking the X clears
the filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:35:59 +02:00
61486a503e feat: sticky month-header overlay in Timeline
The inline date headers can't use CSS position: sticky because
TanStack Virtual positions every item with transform translateY,
which removes them from the document flow.

Workaround: render a separate overlay above the scroll container
that's absolutely positioned (left/right/top: 0) and updates its
label as the user scrolls. The current label is computed from a
pre-built headerOffsets array (cumulative sum of item heights up
to each header) — find the latest header whose offset <= scrollTop,
and that's the group containing whatever's at the top of the view.

The overlay sits at z-20 above the photos with bg-bg/90 +
backdrop-blur and pointer-events-none so it doesn't intercept
clicks. Inline headers still render so the visual flow at group
boundaries is smooth — the overlay is the persistent label.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:32:39 +02:00
5f11698907 feat: sorting + Google-Photos-style date-grouped timeline
Two related changes:

1. Sorting controls
   - filterStore gains sortBy (taken_at | added_at | filename | file_size
     | rating) and sortOrder (asc | desc), defaults taken_at desc.
   - filtersToParams sends sort + order to the backend list endpoint.
   - usePhotosQuery drops the hardcoded sort/order and reads from the
     store.
   - useFilterUrlSync round-trips ?sort= and ?order= so the choice
     persists in the URL.
   - FilterBar gets a Sort group with a field <select> and an asc/desc
     toggle button (ArrowDown / ArrowUp icons).

2. Date-grouped timeline (Google Photos style)
   - When sorted by a date field (taken_at or added_at), the Timeline
     now groups photos by month label ("April 2026") with a small
     header row between groups.
   - Refactored the virtualizer items from "rows of photos" to a flat
     mixed array of header | row items, with per-item heights via the
     virtualizer's estimateSize callback. Headers are 36px, photo rows
     are THUMBNAIL_SIZE + GAP.
   - buildItems() walks photos in order, breaks groups when the month
     label changes, and chunks each group into rows of `columns` cells.
     Photos with no taken_at fall back to "Unknown date".
   - For non-date sorts (filename / file_size / rating) the timeline
     reverts to a single un-headered stream — grouping by month
     wouldn't be meaningful.
   - Range selection and arrow-key nav still operate on the flat
     photos array, so grouping is purely a visual layer.
   - Also fixes a small bug: photo nav arrow-key handler now ignores
     events fired while focus is in an INPUT or TEXTAREA.

Sticky header overlay (the header that stays at the top while you
scroll past photos in its group) is intentionally deferred — inline
headers already give the visual grouping; the sticky behaviour is
polish for a follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:28:16 +02:00
ee6b49952e fix: optimistic membership cache so P toggle is instant and reliable
P already toggled membership in the active heap (remove if every
selected photo is a member, otherwise add the missing ones), but the
mutation only invalidate-then-refetched the heap-photo-ids cache on
success. Pressing P twice in quick succession could read the stale
cache and mis-toggle.

Both heap-toggle mutations (useKeyboardShortcuts P shortcut and the
RightSidebar Pick button) now do an optimistic update in onMutate:
- Read the current ['heap-photo-ids', heapId] cache
- Add or remove the affected ids in a Set
- Write the new array back via setQueryData
- Roll back from the captured `previous` on error
- Re-sync via invalidateQueries in onSettled (heap counts in particular
  still need server truth)

Result: the basket affordance flips the moment you press P, and a
quick second press always reads the new state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:24:29 +02:00
324cc0298b chore: drop duplicate selected count + Discard button from TopBar
Both were redundant:
- "N selected" is already shown by the contextual KeyboardHints pill
  below the FilterBar
- The Discard action is in the RightSidebar Flag section and bound
  to X (and exists as a per-photo button on the thumbnail when
  is_discarded)

Also removes the no-longer-used discardPhotosMutation, the photos
api import, the toast import, the useMutation/useQueryClient imports,
and the usePhotoStore selectedPhotos read — TopBar is leaner now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:22:30 +02:00
351ccd7bb4 refactor: unify Pick with active heap membership
Pick and add-to-heap were two ways of saying "I want to keep this
one". Merging them: P now toggles the selection's membership in the
active heap. The is_picked flag goes away (orphaned in the DB the
same way is_trashed was).

Backend
- Drop is_picked from PhotoBase / PhotoUpdate / PhotoResponse and
  from the photos list filter param.
- Drop the is_picked Column from the Photo model (DB column stays
  on legacy installs but is no longer read or written).
- Drop the bulk action 'pick' branch.
- New GET /heaps/{id}/photo_ids returns just the flat string list.
  Used by the frontend for fast client-side membership lookups
  without fetching full photo records.

Frontend
- New hooks/useActiveHeapMembersQuery.ts → returns
  { activeHeap, memberIds: Set<string> }. Subscribes once at the
  Timeline level and passes a derived isInActiveHeap bool down to
  each PhotoThumbnail (avoids hundreds of thumbnails subscribing
  to the same query).
- PhotoThumbnail: replaces the old check-icon Pick affordance with
  a clear basket badge in the bottom-right corner — a small filled
  pick-colour pill containing a ShoppingBasket icon — visible only
  when the photo belongs to the active heap.
- P shortcut (useKeyboardShortcuts) now toggles membership: if every
  selected photo is already a member, it removes them; otherwise it
  adds the missing ones. T binding removed (P fully replaces it).
- RightSidebar Pick button is now a Pick / Picked toggle bound to
  the active heap. Disabled with a hint when no heap is active.
  Shows the heap name in its title attr.
- filterStore drops 'picked' and 'unflagged' from FlagFilter.
  FilterBar's flag dropdown is now just Any / Discarded.
- LeftSidebar drops the "Flagged" virtual node (it just set
  flag=picked, which no longer exists).
- KeyboardHints: P → "Pick → heap".
- Photo TS type drops is_picked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:20:31 +02:00
02fb1cd508 feat: editable title, notes, and color label in RightSidebar
Adds the missing editable fields from spec §6.6 metadata sidebar.
The mutation path already existed (used by the keyboard culling
shortcuts) — this just surfaces the controls.

- Title: text input. Save on Enter or blur. Esc reverts.
- Notes: textarea, 3 rows. Save on blur.
- Color label: 6-dot picker (red/orange/yellow/green/blue/purple)
  with a clear button. Click an active dot to clear, or use the X.
- Local "draft" state for the text fields so typing stays
  responsive and stale refetches don't clobber in-progress edits.
  Drafts re-sync on photo.id change.
- Sends null for empty string so the backend stores NULL instead
  of an empty string (cleaner for FTS5 / future filtering).

api.ts: widens photos.update() signature to match the backend
PhotoUpdate schema — accepts user_title/user_notes/color_label
(nullable) plus is_picked/is_discarded/taken_at, which were
missing despite already being used by other call sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:10:01 +02:00
ebae775f3f feat: heaps end-to-end with active heap and T shortcut
Adds the spec §6.10 heaps concept: named photo collections with a
single "active" target for fast keyboard adds. Uses a basket icon
(ShoppingBasket) to visually distinguish heaps from folders.

Backend (routers/heaps.py)
- Replaces the 27-line stub with full CRUD: list (with photo counts
  via a single LEFT JOIN), create, patch (rename + set active), delete.
- Add/remove photos endpoints with idempotent semantics: re-adding an
  existing member is a no-op, removing a non-member is a no-op.
- Setting is_active=true on one heap clears the flag on every other
  heap in a single UPDATE so we maintain the single-active invariant.
- routers/photos.py list endpoint now applies the heap_id filter via
  IN-subquery against heap_photos (it was a declared param but had
  no filter logic).

Frontend
- New hooks/useHeapsQuery.ts and useFilterUrlSync wires heap_id as
  another URL-persisted filter; usePhotosQuery threads it through.
- New components/heaps/HeapsPanel.tsx replaces the LeftSidebar Heaps
  stub. Shows the basket icon, photo counts, lets you create heaps
  inline, click to filter the timeline, set active via the target
  icon, and delete heaps.
- TopBar shows an "active heap" pill (basket + name) so the user
  always knows where the next T-press will land.
- KeyboardHints adds T → Add to heap.

T shortcut (useKeyboardShortcuts)
- Reads the active heap from the heaps query cache and the selection
  from the photo store at fire time. Adds the selected photos (or the
  active photo if nothing is selected) via POST /heaps/{id}/photos.
- Toasts:
  - "Added to {heap}: N photos (M already present)" on success
  - "No active heap" hint when none is set
  - "Nothing selected" hint when there's no selection or active photo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:06:52 +02:00
322969c938 feat: discard view with restore and empty actions
Adds the destructive-action loop the discard concept needed:

- Click "Discarded" in the left sidebar → activates the discarded
  filter; the timeline reloads showing discarded photos.
- DiscardActionBar appears at the top of the timeline only when the
  discarded filter is active. Shows the count, a Restore button (when
  photos are selected), and an Empty discard pile button.
- Empty action goes through a ConfirmDialog (new tiny reusable modal,
  same overlay pattern as AddSourceFolderDialog).
- Restore goes through POST /api/v1/discard/restore.
- DELETE /api/v1/discard/empty now actually os.unlink()s the files
  from disk in addition to removing the DB rows. Per-file failures
  are logged and reported in the response so a single permission
  error doesn't abort the batch.

Other library nodes wired in passing:
- "All Photos"   → clearAll()
- "Rated"        → setRatingMin(1)
- "Flagged"      → setFlag('picked')
- "Discarded"    → setFlag('discarded')
- "By Date"      left unwired (needs a date-grouping UI)

Single-photo restore via the U keyboard shortcut already worked from
an earlier round, no change needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:59:34 +02:00
c7d2cc47e1 chore: drop E shortcut for preview, keep only Space
E was a Lightroom holdover and overlapped with the natural rating /
flag culling shortcuts. Space is the only binding now (double-click on
a thumbnail still works). Hints pill updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:56:09 +02:00
997e11db78 refactor: rename trash to discard end-to-end
User-facing labels and code now use "discard" (verb) and "Discarded"
(state/view label) instead of "trash" / "Trashed". The DB column names
stay (is_trashed / trashed_at) so no migration is required — only the
SQLAlchemy attribute names are renamed via Column('old_name', ...).

Backend
- Photo model: is_discarded / discarded_at attributes (DB columns
  unchanged).
- PhotoBase / PhotoResponse / PhotoUpdate schemas use the new field
  names.
- Photos list endpoint: is_discarded query param, filter logic.
- DELETE /photos/{id} now sets is_discarded; success message updated.
- Bulk action 'trash' renamed to 'discard'.
- backend/app/routers/trash.py renamed to discard.py with renamed
  functions and route prefix /api/v1/discard.
- main.py imports and mounts the discard router.
- tasks/scan.py marks missing files as is_discarded.

Frontend
- Photo TS type: is_discarded.
- PhotoThumbnail: shows the trash-can icon when is_discarded.
- RightSidebar: button label "Discard"; mutation field name; local
  variable rename.
- TopBar: discardPhotosMutation and "Discard" button; toast text
  "Discarded".
- LeftSidebar: virtual node id 'discarded' / label "Discarded".
- FilterBar / filterStore / useFilterUrlSync: FlagFilter enum value
  'trashed' → 'discarded'; backend param key is_discarded.
- KeyboardHints: X label "Discard".
- useKeyboardShortcuts: PhotoUpdate field rename, X handler.
- api.ts: /trash routes → /discard, trash export → discard,
  bulkUpdate trash field → discard.

Out of scope (intentional): the docker-compose trash_data volume,
backend/Dockerfile mkdir /data/trash, config.py TrashSettings, and
the spec doc — all unused since soft-discard, and renaming them is
churn for no benefit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:54:31 +02:00
2679214cb9 refactor: rename loupe to preview, bind to E and Space, fix empty viewer
The loupe view is now called "preview" everywhere — file paths, type
names, store actions, and the contextual hint pill. There's a single
preview action bound to E and Space (Enter is gone); double-click on a
thumbnail still works. Both shortcuts toggle: open from grid, close
from preview.

This commit also folds in the fix for the "preview shows nothing" bug
the user just hit:

- Extract usePhotosQuery into frontend/src/hooks/usePhotosQuery.ts so
  Timeline, PreviewView, and App.tsx all share one query — and one
  cache entry. Previously PreviewView and App.tsx looked the cache up
  by ['photos'], but the Timeline query key gained the filter params
  (['photos', filterParams]) when the filter bar shipped, so the
  lookup returned undefined and the preview rendered "No photo to
  display". App.tsx's getFirstPhotoId callback had the same bug.

- Harden PreviewImage: render the <img> immediately and overlay the
  spinner with absolute positioning, instead of toggling opacity-0 →
  opacity-100 on load. The previous opacity-toggle could leave the
  image stuck invisible if the load event raced with a key change.

- Add { preventDefault: true } to every useHotkeys call so single
  letter shortcuts (1-5, P, X, U) no longer leak into Firefox quick-
  find, and Cmd/Ctrl+F no longer triggers the browser find toolbar.

Files renamed:
  components/loupe/LoupeView.tsx       -> components/preview/PreviewView.tsx
  components/loupe/LoupeImage.tsx      -> components/preview/PreviewImage.tsx
  components/loupe/LoupeFilmstrip.tsx  -> components/preview/PreviewFilmstrip.tsx
  components/loupe/loupeSrc.ts         -> components/preview/previewSrc.ts

Symbol renames: openLoupe→openPreview, closeLoupe→closePreview, the
viewMode 'loupe' tag → 'preview', and all the LoupeXxx component and
helper exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:47:52 +02:00
ce2cda0565 refactor: merge reject into trash as a single soft-trash concept
The Photo model previously had two near-identical "negative culling"
states: is_rejected (a flag) and is_trashed (a flag plus a file move).
Lightroom users typically use one or the other, never both, and the
file-move semantics of the old trash made it harder to undo. Merging
into a single soft is_trashed flag — file stays on disk, restore is a
flag flip, permanent deletion still happens via DELETE /trash/empty.

Backend
- Drop is_rejected from PhotoBase, PhotoResponse, PhotoUpdate, the
  list endpoint filter, and the bulk-action 'reject' branch.
- Add is_trashed to PhotoUpdate so the PATCH path can set it.
- Drop is_rejected Column declaration from the SQLAlchemy model. The
  legacy DB column may persist on existing installs but is no longer
  read or written; SQLAlchemy ignores extra columns.
- Rewrite DELETE /photos/{id} as a soft trash: just sets is_trashed=
  true and trashed_at=now, no shutil.move. Permanent deletion still
  goes through the trash router.

Frontend
- Photo TS type drops is_rejected, gains is_trashed.
- X keyboard shortcut now sets is_trashed=true (was is_rejected); U
  clears both is_picked and is_trashed.
- RightSidebar Reject button → Trash button (Trash2 icon).
- PhotoThumbnail flag overlay shows Trash2 icon for trashed photos
  instead of an X for rejected.
- KeyboardHints relabels X from "Reject" to "Trash".
- filterStore FlagFilter renames 'rejected' → 'trashed'; the params
  builder now sends is_trashed=true for the trashed filter (the list
  endpoint defaults to hiding trashed photos otherwise).
- FilterBar dropdown / URL sync allow-list updated accordingly.

No data migration: existing rejected photos remain as-is (flag stale)
and effectively become unflagged in the new model. Re-trash from the
UI to bring them into the new state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:16:10 +02:00
f7bf22db29 chore(docker): add proxies_data volume for /proxy endpoint cache
The /photos/{id}/proxy endpoint (added in 1096854) caches transcoded
RAW/HEIC WebPs at /data/proxies/{id}.webp, but the compose file had no
volume mount for that path — files would be lost on every container
restart, forcing repeated full-resolution decodes. Adding a named
volume to both backend and worker so the cache survives restarts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:15:43 +02:00
3e322576f2 chore: drop static shortcut drawer; inline contextual hints
The bottom-left KeyboardShortcuts drawer duplicated information that
the contextual KeyboardHints pill already shows for the current
selection state. Removing it in favor of the contextual hints alone.

KeyboardHints was previously a fixed top-14 overlay, which collided
with the FilterBar when it was opened — the hints panel covered the
filter controls. Refactored it to render inline in the App header
stack (TopBar / FilterBar / ActiveFilterChips / KeyboardHints /
Timeline) so it flows naturally and never overlaps.

Also:
- Hide hints in loupe mode (the loupe has its own context)
- Replace the deleted shortcuts (Ctrl+A, Trash) with the newly wired
  ones (\\ Filters, / Search, E Loupe) so the hints surface them

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:15:31 +02:00
6e6672f225 fix: use square cells in timeline grid
PhotoThumbnail computed cell height as size * min(aspectRatio, 1.5),
so portrait photos overflowed their row. The TanStack Virtual row
estimate is a single fixed value (thumbnailSize + gap), so any cell
taller than that pushed into the row below — visible as overlapping
thumbnails whenever a portrait shared a row with landscapes.

Switching to square cells (Lightroom Library default) means every row
is exactly the estimated height. The image still fills via object-cover,
just cropped on the long axis.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:15:19 +02:00
d2155d9dd2 feat: filter bar with search, URL sync, and active-filter chips
Adds the spec §6.7 filter bar to the top of the timeline with:
  - Date range (native date inputs)
  - Media type chips (Photo / Video / RAW / HEIC, multi-select)
  - Min star rating (click to set, click again to clear)
  - Color label dots (red/orange/yellow/green/blue/purple, single)
  - Flag (any / picked / rejected / unflagged)
  - Clear-all button

Active filters surface as removable chips below the bar so they're
visible whether the bar is collapsed or open. The TopBar search input
is now wired to the same filter store with a 300ms debounce, and shows
a clear button when populated.

Filter state is the source of truth in a Zustand store and round-trips
through the URL via history.replaceState — bookmarkable and shareable
per spec §6.7. Hydrate happens once on mount; subsequent store changes
write back to ?q=&date_from=&… without navigation.

Timeline reads filter state, builds the backend params via
filtersToParams, and includes them in the React Query key so the cache
invalidates on every filter change. Also fixes a latent bug: Timeline
was sending limit=1000&offset=0, which the backend silently ignores —
swapped to page=1&per_page=500 with explicit sort=taken_at&order=desc.

New keyboard shortcuts:
  - \\  toggles the filter bar
  - /   focuses the TopBar search input
  - Cmd/Ctrl+F  same as /

Filter button in the TopBar now lights up when filters are active or
the bar is open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:50:58 +02:00
892e8e1da4 feat: wire culling keyboard shortcuts to photo mutations
Replaces the console.log stubs in useKeyboardShortcuts with real
PATCH /photos/{id} mutations against the active photo, so 1-5 / 0 / P /
X / U / 6-9 actually rate, flag, and color-label photos. Mutations
invalidate both the photo detail query and the timeline list query, so
the RightSidebar and grid update immediately.

Shortcuts now work in BOTH grid and loupe modes (the previous
{ enabled: isGrid } gate is removed) so the user can cull while
browsing in the loupe — the Lightroom workflow.

Color labels 6-9 are wired to red/orange/yellow/green per spec §6.4.

Active photo id is read fresh via usePhotoStore.getState() inside each
handler, so we don't re-bind hotkeys on every selection change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:44:47 +02:00
9089ad2f61 feat: wire RightSidebar to real photo metadata
Replaces the mock photo data with a TanStack Query fetch driven by
activePhotoId, so the metadata panel reflects the photo currently
selected (or being viewed in the loupe). Parses exif_json defensively
and renders ExifTool fields with sensible aliases (Make/Model,
LensModel/Lens, FNumber, ExposureTime/ShutterSpeedValue, FocalLength,
GPSLatitude/Longitude). Falls back to '—' for missing fields.

Rating stars and Pick/Reject buttons now fire useMutation against
PATCH /photos/{id} and invalidate both the photo detail query and the
['photos'] list query so the timeline grid reflects the change too.

Multi-select keeps its bulk-action footer and shows the selection
count instead of metadata.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:39:48 +02:00
f4fc15101e feat: loupe view with zoom, pan, video, and filmstrip
Adds the Lightroom-style full-screen single-photo viewer (spec §6.11).
Open with E / Enter / double-click; navigate with arrow keys; Esc returns
to grid. Filmstrip at the bottom auto-scrolls the active cell into view.

Display:
- Stills source from /photos/{id}/proxy so RAW/HEIC are decoded server-
  side; large thumbnail is the onError fallback only.
- Videos render in a <video controls> sourced from /original.
- Continuous wheel zoom (1×–8×, ~15% per tick) with click-drag pan when
  zoomed past fit. Z toggles between fit and natural-resolution
  (computed from naturalWidth / clientWidth); a second Z snaps back.
- Live percentage indicator in the bottom-center while zoomed.

Polish:
- Neighbor preloading via new Image() when currentIndex changes so arrow
  nav feels instant (skips videos).
- Focus trap with role=dialog, aria-modal, focus-on-mount, restore-on-
  unmount, and Tab cycling among focusable children.

Plumbing:
- New canonical Photo TS interface in types/photo.ts; removes the three
  duplicated definitions in PhotoThumbnail/Timeline/photoStore.
- photoStore gains viewMode + openLoupe/closeLoupe.
- useKeyboardShortcuts wires E/Enter/G to open/close, gates rating and
  flag stubs with { enabled: viewMode === 'grid' } so they're inert in
  loupe.
- App.tsx mounts <LoupeView/> as a z-40 overlay covering TopBar, gates
  the auto-open right sidebar effect on viewMode === 'grid' so leaving
  loupe doesn't fight the user's prior sidebar state.
- PhotoThumbnail gains an onDoubleClick prop wired to openLoupe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:39:33 +02:00
1096854553 feat: backend /proxy endpoint for full-res RAW/HEIC display
Adds GET /photos/{id}/proxy that decodes RAW (rawpy), HEIC (pillow-heif),
and TIFF to a cached full-resolution WebP at /data/proxies/{id}.webp.
Web-safe formats (JPEG/PNG/WebP/GIF) pass through to the original to
avoid pointless transcoding. RAW failures fall back to extracting the
embedded JPEG preview. Mirrors the X-Accel-Redirect pattern from the
existing thumb endpoint.

Also fixes GET /photos/{id}/original to return the correct image/jpeg,
image/png, video/mp4, etc. content types instead of always serving
application/octet-stream, so <img> and <video> tags can render the
file inline rather than triggering a download.

Frontend: adds photos.getProxyUrl() helper in services/api.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:39:01 +02:00
72d301a9c7 feat: resilient thumbnail loading with backoff retries
Backend generates thumbnails on-demand via Celery, so the first request
often 404s while the worker runs. Auto-retry with 1.5s/3.5s/6s backoff,
manual retry fallback, and proper timer cleanup so fast-scrolling a
virtualized timeline doesn't setState on unmounted components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:59:03 +02:00
174 changed files with 22061 additions and 11682 deletions

23
.env
View File

@@ -1,23 +0,0 @@
# Environment variables for Mulita
# Photo directories to mount (can be multiple paths separated by colon)
# Example: /path/to/photos1:/path/to/photos2
PHOTO_DIRS=./photos
# Redis configuration
REDIS_URL=redis://localhost:6379
# Database URL
DATABASE_URL=sqlite+aiosqlite:///data/db/mulita.db
# Celery configuration
CELERY_BROKER_URL=redis://localhost:6379
CELERY_RESULT_BACKEND=redis://localhost:6379
CELERYD_CONCURRENCY=4
# API settings
API_HOST=0.0.0.0
API_PORT=8000
# Frontend settings
VITE_API_URL=http://localhost:8000

83
.env.example Normal file
View File

@@ -0,0 +1,83 @@
# Example environment file. Copy to `.env` and adjust.
#
# podman-compose --env-file .env \
# -f docker-compose.yml -f docker-compose.podman.yml up -d
# ── REQUIRED ─────────────────────────────────────────────────────────────────
# Host path to your photo library. PhotoPrism reads this in place and
# writes EXIF backwrites next to originals (when PP_ORIGINALS_MODE=rw).
PHOTO_DIRS=/mnt/library/homecloud/admin/files/
# Bootstrap admin password. The first PhotoPrism boot creates an `admin`
# account with this password. Rotate after first login from the UI.
PP_ADMIN_PASSWORD=please-change-me
# MariaDB passwords. Generate with `openssl rand -hex 24`.
PP_DB_PASSWORD=please-change-me
PP_DB_ROOT_PASSWORD=please-change-me
# ── OPTIONAL ─────────────────────────────────────────────────────────────────
# Loopback host port for PhotoPrism's API (and UI, if you tunnel to it).
# Vite proxies /api/v1/* here and the host-mode sidecar reaches it on
# localhost. Not published on the public interface.
PP_PORT=2342
# Site URL — used for share links, OIDC redirect URI, and reverse-proxy aware
# URL generation. Set to the public hostname once the proxy is in front.
PP_SITE_URL=http://localhost:2342/
# Auth mode — "password" for username/password (default), "public" for an
# unauthenticated kiosk mode (don't use this on a multi-user library).
PP_AUTH_MODE=password
# Library mount mode. "rw" allows rename / folder mutations / EXIF backwrite;
# "ro" is safe-for-archives but disables those sidecar endpoints. Set in
# lockstep with PP_READONLY below.
PP_ORIGINALS_MODE=rw
PP_READONLY=false
# UID/GID inside the PhotoPrism container. Set these to the host UID/GID that
# owns ${PHOTO_DIRS}. `id -u` and `id -g`.
PP_UID=1000
PP_GID=1000
# ── OIDC SSO (Authentik or equivalent) ───────────────────────────────────────
# Leave blank to keep OIDC dormant. Fill in to enable the "Sign in with OIDC"
# button on the login page; OIDC_REGISTER=true auto-creates accounts at role
# `user` (override to `admin` to grant full access on first SSO login).
#
# The compose file reads these and maps them to PhotoPrism's actual env-var
# names (PHOTOPRISM_OIDC_URI / _CLIENT / _SECRET / _PROVIDER) — see the
# comment in docker-compose.yml. The PhotoPrism callback URI is auto-derived
# from PP_SITE_URL; do not set it manually.
# OIDC_PROVIDER_NAME=Authentik
# OIDC_ISSUER_URL=https://auth.example.com/application/o/photoprism/
# OIDC_CLIENT_ID=...
# OIDC_CLIENT_SECRET=...
# OIDC_SCOPES=openid profile email
# OIDC_REGISTER=true
# OIDC_ROLE=user
# ── USER LIBRARY ISOLATION ───────────────────────────────────────────────────
# Maps PhotoPrism usernames to originals-relative subdirectories so each
# user only sees their own photos. Format: comma-separated user:path pairs.
# The sidecar reconciler applies this to auth_users.base_path on boot and
# every 60s. Leave empty for single-user deployments.
#
# USER_BASEPATHS="alice:alice, bob:bob"
# Sidecar DB password — provisioned by mariadb/init/01-sidecar.sql on first
# boot. Rotate before any non-local deployment.
# SIDECAR_DB_PASSWORD=replace-at-m4-bringup
# ── LOGGING ──────────────────────────────────────────────────────────────────
PP_LOG_LEVEL=info

12
.gitignore vendored
View File

@@ -34,6 +34,7 @@ dist-ssr/
.DS_Store
# Environment
.env
.env.local
.env.*.local
@@ -60,9 +61,18 @@ build/
# Docker
docker-compose.override.yml
# PhotoPrism state (sidecars, cache, thumbs, db backups) — regenerable.
/pp/storage/
/pp/import/
# Sidecar runtime state (per-user marks etc.) — generated, not seed data.
/sidecar/data/
# Sidecar Go build output.
/sidecar/mule-sidecar
# Photos (for development)
/photos/
# Thumbnails
/thumbs/
/trash/

View File

@@ -0,0 +1,247 @@
# Plan: Populate `photos_users` to fix label isolation in PhotoPrism
**Date:** 2026-06-06
**Author:** Hermes Agent
**Status:** Draft
---
## 1. Goal
Fix the label isolation leak where a user with `base_path` set (e.g. `muli`) sees photos from other users' directories (e.g. `dtoro`) in PhotoPrism's labels view.
## 2. Current Context
### The problem
- PhotoPrism's `base_path` feature correctly scopes the main search (`/api/v1/photos`).
- Label views (`/api/v1/labels`) do **not** respect `base_path` — labels show photo counts and thumbnails from the entire library.
- User reports: "all photos on the main labels page are a mix of both" muli and dtoro.
### What we know
- **53 files changed** in the sidecar (Go + GORM, gorm.io/gorm v1.31.1).
- Sidecar has a working PhotoPrism DB connection via `PpDSN` (user: `photoprism`, schema: `photoprism.*`).
- The `photos_users` table exists in PhotoPrism's MariaDB schema but is **empty** (0 rows).
- Schema of `photos_users`:
```
photos_users:
uid varbinary(42) NOT NULL PRI (composite PK or single?)
user_uid varbinary(42) NOT NULL PRI
team_uid varbinary(42) YES MUL
perm int(10) unsigned YES
```
- Known user UIDs: `dtoro=utfetfdk0so2z9zl`, `muli=utg7jjbd8iwaghn6`
- Known base paths: `dtoro→dtoro`, `muli→muli`
- The `photo_path` column in `photos` stores paths like `muli/files/Photo Archive...` or `dtoro/Memories/...`
- Currently: ~88K photos, ~52K files indexed.
### The sidecar's current reconciler (`users.go`)
- Runs every 60s.
- Only calls `UPDATE auth_users SET base_path = ? WHERE user_name = ?`.
- Does **not** touch `photos_users`.
### Unknowns
1. **Does PhotoPrism use `photos_users` for general label filtering?** The table appears designed for explicit sharing (e.g. share a specific photo with another user), not for base_path ACL. PhotoPrism may ignore `photos_users` in label queries.
2. **Performance impact**: 88K photos × 2 users = up to 176K rows. Could slow label queries.
3. **Side effects**: If `photos_users` controls sharing, adding auto-entries might break explicit share workflow.
4. **`perm` values**: Unclear what `perm` value grants "view" access. Likely a bitmap (bit 0 = view).
## 3. Proposed Approach
### Phase 1: Investigate (prove the approach works before building)
**Step 1.1: Insert test rows into `photos_users` manually**
On the production DB, insert a few `photos_users` entries for muli mapping to some of muli's own photos, plus one entry mapping to a dtoro photo. Use a guessed `perm` value (e.g. `1` = view).
Then check:
- Does muli see fewer photos now? (If `photos_users` works as an exclusive ACL, yes.)
- Does the dtoro photo with a `photos_users` entry for muli show up for muli?
- Does the label view change?
**Step 1.2: Test with `perm` variations**
If `perm=1` does nothing, try `perm=2`, `perm=7`, or `perm=15` (common Unix-ish bitmap patterns).
**Step 1.3: Examine PhotoPrism source**
Check PhotoPrism's search/label code to confirm whether `photos_users` is joined in label queries. This tells us definitively whether the approach is viable.
### Phase 2: Build (if Phase 1 confirms the approach works)
**Step 2.1: Add `photos_users` GORM model**
New struct in `db.go` or a new file `perms.go`:
```go
type PhotoUser struct {
PhotoUID string `gorm:"primaryKey;size:42;column:uid"`
UserUID string `gorm:"primaryKey;size:42;column:user_uid"`
TeamUID string `gorm:"size:42;column:team_uid"`
Perm int `gorm:"column:perm"`
}
func (PhotoUser) TableName() string { return "photos_users" }
```
Note: GORM `AutoMigrate` is called on `mule_sidecar` schema, not `photoprism.*`. The `photos_users` table already exists in the `photoprism` schema — we only query/insert, never migrate.
**Step 2.2: Add `reconcilePhotoUsers` function**
New function in a new file `perms.go` alongside `users.go`. Signature:
```go
func reconcilePhotoUsers(ppDSN, originalsRoot string, mapping map[string]string) error
```
Logic:
1. For each `username:path` pair in `mapping`, look up the user's `user_uid` in `auth_users`.
2. Query `photos` for all `photo_uid` where `photo_path LIKE 'path/%'`.
3. Batch-insert entries into `photos_users` with a default `perm` value (to be determined in Phase 1).
4. Use `INSERT IGNORE` or `ON DUPLICATE KEY UPDATE` for idempotency.
5. Handle deletions: if a photo's path is changed (via rename), the old `photos_users` entry should be cleaned up.
**Step 2.3: Wire into reconciler loop**
Extend the existing `startUserBasepathReconciler` to call `reconcilePhotoUsers` after `reconcileUserBasepaths`.
```go
func apply() {
reconcileUserBasepaths(...)
reconcilePhotoUsers(...)
}
```
**Step 2.4: Handle re-index edge cases**
- When new photos are indexed, they won't have `photos_users` entries until the next 60s tick.
- Could add a webhook or a one-shot trigger after PhotoPrism's index completes.
- Alternative: accept the 60s lag as a design trade-off (current base_path reconciler already has this lag).
### Phase 3: Validate (if Phase 1 confirms)
1. Build the binary: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
2. Rebuild the Docker image and restart the sidecar.
3. Check `photos_users` has expected rows.
4. Log in as `muli` via Authentik SSO, browse labels — verify dtoro photos are gone.
5. Log in as `dtoro` — verify still sees own photos.
6. Verify no regression: search, album, folder views still work for both users.
## 4. Files Likely to Change
| File | Change |
|------|--------|
| `sidecar/perms.go` | **New file**`PhotoUser` model, `reconcilePhotoUsers` function |
| `sidecar/db.go` | Add `photos_users`-related constants/helpers (optional) |
| `sidecar/users.go` | Extend `reconcileUserBasepaths` or add a phase to the existing reconciler |
| `sidecar/main.go` | Wire the new reconciler phase (minor — call from existing ticker) |
| `sidecar/Dockerfile` | Unchanged (Go build picks up new `.go` files automatically) |
## 5. Tests & Validation
1. **Build check**: `go build ./...` from `sidecar/`
2. **Manual DB test** (Phase 1): Insert test `photos_users` rows via `docker exec pp-mariadb mysql ...`
3. **Integration test**: After deploy, check `photos_users` row count matches expected photo count per user.
4. **Label isolation check**: Browse labels as each user — confirm no cross-user leaks.
## 6. Source Code Analysis (Completed)
### How base_path scoping works in PhotoPrism
Found the critical function `ScopePhotosForSession` in `internal/entity/search/photos_scope.go`:
```go
func ScopePhotosForSession(stmt *gorm.DB, sess *entity.Session) *gorm.DB {
// Admin/library role → no scoping needed
if sess == nil || acl.Rules.AllowAny(acl.ResourcePhotos, sess.GetUserRole(), acl.Permissions{acl.AccessAll, acl.AccessLibrary}) {
return stmt
}
user := sess.GetUser()
if basePath := user.GetBasePath(); basePath == "" {
return stmt.Where(sharedAlbums + "photos.created_by = ? OR ...", ...)
} else {
return stmt.Where(sharedAlbums + "... OR photos.photo_path = ? OR photos.photo_path LIKE ?",
..., basePath, basePath + "/%")
}
}
```
Key: base_path filtering is done by adding `WHERE photos.photo_path LIKE 'muli/%'` to the SQL query. It is **NOT** done via `photos_users`.
### How endpoints use base_path
| Endpoint | Function | Applies base_path? |
|----------|----------|-------------------|
| `GET /api/v1/photos` | `SearchPhotos``UserPhotos``searchPhotos`**`ScopePhotosForSession`** | ✅ Yes |
| `GET /api/v1/labels` | `SearchLabels``search.Labels(frm)` — no session passed | ❌ **No** |
| Review tab | Uses `GET /api/v1/photos?quality=3` → goes through `ScopePhotosForSession` | ✅ Should scope |
| Archive tab | Uses `GET /api/v1/photos?archived=true` → goes through `ScopePhotosForSession` | ✅ Should scope |
| Albums | TBD — depends on whether they use `ScopePhotosForSession` | ⚠️ Unknown |
### The `photos_users` table
Found in `internal/entity/photo_user.go`:
```go
type PhotoUser struct {
UID string // photo_uid
UserUID string // user_uid
TeamUID string // team_uid
Perm uint // permission bitmap
}
```
This table is **not referenced** in `ScopePhotosForSession`, `searchPhotos`, or any label/album search function. It is only used for **explicit sharing** (via `FirstOrCreatePhotoUser` called when sharing a specific photo with another user).
**Conclusion: Populating `photos_users` will NOT fix the label, review, or archive tab isolation.** PhotoPrism does not consult this table for any of these queries.
### Why review/archive might show cross-user photos
Since review and archive use `GET /api/v1/photos` which goes through `ScopePhotosForSession`, they **should** be scoped. The issue might be:
1. **Pre-computed counts** in the sidebar tabs show total numbers across all users
2. **Label thumbnails** and category summaries are computed from the `labels` table which is global
3. The actual photo list in review/archive should be correctly scoped — the user may be seeing dtoro photos only in the summary/counts
### DB experiment results
Confirmed `photos_users` is empty (0 rows). Inserted 100 muli-photo entries + 1 dtoro-photo entry for muli with `perm=1`. Label API response unchanged — `photo_count` values remained the same (Dog: 733, Cat: 57), confirming labels ignore `photos_users`.
## 7. Updated Recommendation
**Abandon the `photos_users` approach.** It won't fix the problem because PhotoPrism never consults this table for labels, review, or archive queries.
### Real fix options
1. **Sidecar label filter** (recommended) — The sidecar already validates sessions via `resolveSession()` which returns the user's `BasePath`. Extend the sidecar to expose a **proxied `/api/v1/labels`** endpoint that:
- Accepts the caller's `X-Auth-Token` (already validated by `requireSession`)
- Forwards the request to PhotoPrism's `/api/v1/labels`
- **Filters the response** to remove labels whose `Thumb` belongs to a photo outside the user's `base_path`
- Recalculates `PhotoCount` for the user's scope (count photos under `base_path/%` for that label)
- Also filter `Count` values in the sidebar summary response
**Why this works:** The sidecar already has DB access to PhotoPrism's schema (`PpDSN`) and validates sessions. It can query `photos` to count label intersections per base_path.
2. **Same approach for review/archive sidebar counts** — Intercept the relevant metadata/summary endpoints to scope counts by base_path.
3. **Accept the limitation** — Labels show cross-user thumbnails/counts but the actual photo list is scoped.
### Implementation sketch for option 1
```
sidecar/
├── proxy.go # New file
│ ├── handleLabels(c) → GET /api/sidecar/labels → proxies to PP, filters by base_path
│ ├── handleReviewCount(c) → GET /api/sidecar/review → returns scoped count
│ └── handleArchiveCount(c) → GET /api/sidecar/archive → returns scoped count
```
The SvelteKit frontend would call `/api/sidecar/labels` instead of `/api/v1/labels`.
### Clean up: remove test rows from photos_users
Since the approach won't work, remove the test rows inserted during Phase 1:
```sql
DELETE FROM photoprism.photos_users WHERE user_uid = 'utg7jjbd8iwaghn6';
```

View File

@@ -0,0 +1,301 @@
# Plan: Fix user isolation in PhotoPrism — labels, review, and archive views
**Date:** 2026-06-06
**Author:** Hermes Agent
**Status:** Draft
---
## 1. Goal
Fix the three views where a user with `base_path` set (e.g. `muli`) sees photos from other users (e.g. `dtoro`):
1. **Labels** — labels list + label drill-down show all library photos
2. **Review** — photos needing review tab shows cross-user photos
3. **Archive** — archived photos tab shows cross-user photos
## 2. Current Context & Source Analysis
### 2.1 How base_path scoping works
PhotoPrism's `ScopePhotosForSession` (in `internal/entity/search/photos_scope.go`) is the only function that enforces user isolation. It adds `WHERE photos.photo_path = '<basePath>' OR photos.photo_path LIKE '<basePath>/%'` to the SQL query.
This is called by `searchPhotos()` — used by the **`GET /api/v1/photos`** endpoint (search, timeline, folders).
### 2.2 How endpoints use base_path
| View | Endpoint | Function chain | Applies base_path? |
|------|----------|----------------|-------------------|
| Main timeline | `GET /api/v1/photos` | `SearchPhotos``searchPhotos``ScopePhotosForSession` | ✅ Yes |
| Folders | `GET /api/v1/photos` with path filter | Same chain | ✅ Yes |
| **Labels** | `GET /api/v1/labels` | `SearchLabels``search.Labels(frm)`**no session** | ❌ **No** — queries `labels` table directly |
| Label drill-down | `GET /api/v1/photos?label=X` | Uses `searchPhotos``ScopePhotosForSession` | ✅ Should scope (if label= param doesn't bypass) |
| **Review tab** | `GET /api/v1/photos?q=review:true` | `searchPhotos``ScopePhotosForSession` | ✅ Should scope, BUT... |
| **Archive tab** | `GET /api/v1/photos?q=archived:true` | Same | ✅ Should scope, BUT... |
### 2.3 The review/archive problem: ACL overrides
In `searchPhotosForm()` (internal/api/photos_search.go):
```go
if acl.Rules.Deny(acl.ResourcePhotos, s.GetUserRole(), acl.ActionManage) {
frm.Quality = 3
}
```
For role=user, `Deny(ActionManage)` → true → sets `frm.Quality = 3` (minimum quality).
Then in `searchPhotos()` (internal/entity/search/photos.go):
```go
if acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.ActionDelete) {
frm.Archived = false
frm.Review = false
}
```
For role=user, `Deny(ActionDelete)` → true → **overrides `review:true` and `archived:true` to false**.
So the review and archive filters are **completely ignored** for the `user` role. The frontend sends `review:true` but the server discards it. The result: the review/archive tabs show ALL photos scoped by base_path (no quality/review/archive filter), which means basically the same as the main timeline.
### 2.4 Label problem: no session scoping at all
`search.Labels()` queries the `labels` table directly with a `WHERE photo_count > 0` clause. There is no session parameter, no `ScopePhotosForSession`, and no base_path or user filtering whatsoever. Labels are **library-wide** in PhotoPrism.
The label drill-down (click into a label) uses `GET /api/v1/photos?label=X` which DOES go through `ScopePhotosForSession`, so the photo list itself should be scoped — but the label thumbnails, counts, and covers are global.
### 2.5 ACL GrantDefaults — missing RoleUser entry
```go
var GrantDefaults = Roles{
RoleAdmin: GrantFullAccess, // FullAccess = AccessLibrary + everything
RoleGuest: GrantReactShared, // Only shared content
RoleVisitor: GrantViewShared, // Only shared content
RolePortal: GrantFullAccess,
RoleClient: GrantFullAccess,
// RoleUser and RoleViewer are NOT listed → fallback to RoleDefault (also missing) → denied
}
```
Because `RoleUser` is absent from `GrantDefaults`, the `Allow()` function falls back to `RoleDefault` which is also absent → returns `false` for all permissions. This means:
- `ScopePhotosForSession` correctly enters the `base_path` branch (good — user is isolated)
- BUT `ActionDelete` is denied → review/archive filters are forced off (bad — can't browse review/archive)
### 2.6 The `photos_users` table — ruled out
`internal/entity/photo_user.go` defines `PhotoUser` but it is **not referenced** in `ScopePhotosForSession`, `searchPhotos`, or any label/album search function. Populating it won't fix any of these issues.
## 3. Proposed Approach
### Phase 1: Sidecar proxy for labels (direct fix)
Extend the sidecar to expose a **scoped labels endpoint**:
```
GET /api/sidecar/labels → proxies to GET /api/v1/labels → filters by base_path
```
**How it works:**
1. Sidecar receives the caller's `X-Auth-Token`
2. `requireSession` middleware resolves the token → returns user's `BasePath`
3. Sidecar makes the same `/api/v1/labels` request to PhotoPrism (using the caller's token)
4. **Filter step**: for each label in the response, query the DB to count photos with that label AND `photo_path LIKE '<base_path>/%'`
5. Return filtered labels with corrected `PhotoCount` and `Thumb`
**Implementation:**
New file `sidecar/handlers_labels.go`:
```go
// handleLabels proxies to PP's /api/v1/labels, then post-filters
// counts and thumbnails by the caller's base_path.
func handleLabels(pp *ppClient, ppDSN string) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
user := ctxUser(c) // resolved from session, includes BasePath
// 1. Get raw labels from PhotoPrism
resp, _ := pp.call(c, "GET", "/api/v1/labels?"+c.Request.URL.RawQuery, token, nil)
// 2. Decode labels
var labels []PpLabel
json.Unmarshal(resp.Body, &labels)
// 3. For each label, recalculate count for this user's base_path
for i, l := range labels {
// Count photos with this label AND where photo_path matches base_path
var count int
db.Raw(`SELECT COUNT(*) FROM photos_labels pl
JOIN photos p ON pl.photo_uid = p.photo_uid
WHERE pl.label_uid = ? AND p.photo_path LIKE ?`,
l.UID, user.BasePath+"/%").Scan(&count)
labels[i].PhotoCount = count
// If count is 0, the thumb from the global label doesn't apply
// Could also update thumb to a user-scoped one
}
c.JSON(http.StatusOK, labels)
}
}
```
**Frontend change:** Update the label query in `web/src/routes/tags/[category]/[[value]]/+page.svelte` to call `/api/sidecar/labels` instead of `/api/v1/labels`.
### Phase 2: Fix review/archive — skip the ACL override
Two options:
**Option A (Recommended): Sidecar proxy for photos search**
Extend the sidecar with:
```
GET /api/sidecar/photos → proxies to GET /api/v1/photos → adds path filter
```
The sidecar intercepts the photos request and adds the `path:<basePath>` query parameter to PhotoPrism's API call. This forces PhotoPrism to add `WHERE photo_path LIKE '<base_path>/%'`.
For review/archive, the sidecar also adds `review:true` or `archived:true` BEFORE the ACL override happens (since the sidecar doesn't hit the ACL code).
**Option B: Custom frontend query**
The frontend explicitly adds `path:muli` to the query string for review/archive tabs:
```
GET /api/v1/photos?q=review:true path:muli&count=50
```
The `path` filter is a standard PhotoPrism search operator that adds `WHERE photos.photo_path = '<path>'`. But this only matches the exact path, not `path/%` (subdirectories). The `path:` operator does `photo_path = ?` (exact match) per the code at line 668.
**Option A is better** because:
- Works for all users without frontend changes
- Can add the proper `LIKE` prefix match
- Centralized logic in the sidecar
### Phase 3: Sidecar proxy for sidebar counts
The session response (or `GET /api/v1/config`) includes library-wide counts:
```json
"count": {
"review": 248,
"archived": 94,
"all": 88203,
"photos": 88000
}
```
These show the TOTAL across all users. The sidecar can proxy this and recalculate counts per base_path.
## 4. Step-by-step Plan
### Step 1: Sidecar — labels proxy
Files: `sidecar/handlers_labels.go` (new), `sidecar/main.go` (route wiring)
1. New types: `PpLabel` (mirrors PhotoPrism's label response shape)
2. Handler function `handleLabels()` that:
- Validates token via `requireSession`
- Gets `BasePath` from session
- Calls PhotoPrism's `/api/v1/labels`
- For each label, queries photos_labels + photos to count user-scoped photos
- Returns filtered labels
3. Wire route: `auth.GET("/labels", handleLabels(...))` in `main.go`
4. Frontend: change label fetch URL from `/api/v1/labels` to `/api/sidecar/labels`
### Step 2: Sidecar — photos proxy (review/archive fix)
Files: `sidecar/handlers_photos.go` (new), `sidecar/main.go` (route wiring)
1. Handler function `handlePhotos()` that:
- Validates token
- Gets `BasePath` from session
- Parses the query string to detect `review:true` or `archived:true`
- Forwards to PhotoPrism's `/api/v1/photos` with `path:<basePath>` added to query
- For review/archive, also ensures `review/archived` filter is NOT stripped
- Returns PhotoPrism's response
2. Two implementation variants:
**Variant A** (simpler): add `path:<basePath>` to the forwarded query. This only matches exact path, not subdirs (PhotoPrism's `path:` operator does exact match). Might miss photos in subdirectories.
**Variant B** (correct): Forward the query without path, then post-filter the response to remove photos whose `photo_path` doesn't match `basePath/%`. This is more robust.
### Step 3: Validation
1. Build sidecar: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
2. Rebuild Docker image: `docker compose build sidecar`
3. Restart sidecar: `docker compose up -d sidecar`
4. Test labels as muli — verify only muli's labels appear
5. Test review tab as muli — verify only muli's photos needing review appear
6. Test archive tab as muli — verify only muli's archived photos appear
7. Test same views as admin — verify dtoro still sees all
## 5. Files Likely to Change
| File | Change |
|------|--------|
| `sidecar/handlers_labels.go` | **New** — label proxy handler |
| `sidecar/handlers_photos.go` | **New** — photos proxy handler (or merged into one proxy.go) |
| `sidecar/handlers_folder.go` | Reference for existing handler patterns |
| `sidecar/main.go` | Wire new routes under `auth` group |
| `sidecar/pp.go` | May need new helper methods for label/photo API calls |
| `sidecar/users.go` | No change |
| `sidecar/db.go` | May add types for PpLabel, PpPhoto |
| `web/src/routes/tags/[category]/[[value]]/+page.svelte` | Change label fetch URL |
| `web/src/lib/stores/filters.svelte.ts` | Possibly change how review/archive queries are built |
## 6. Tests & Validation
**Build**: `cd sidecar && go build ./... && go vet ./...`
**Manual validation on LXC 120:**
```bash
# Test labels endpoint
curl -s "http://localhost:8000/api/sidecar/labels?count=5" \
-H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);[print(l.get('Name','?'),l.get('PhotoCount')) for l in d[:5]]"
# Test photos endpoint with review
curl -s "http://localhost:8000/api/sidecar/photos?q=review:true&count=5" \
-H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(f'{len(d)} photos')"
# Verify vs. admin token — counts should differ
```
**Cross-user check:** Log in as `muli` and `dtoro` in separate browser sessions. Verify:
- Labels show different counts per user
- Review photos are scoped per user
- Archive photos are scoped per user
## 7. Risks, Tradeoffs & Open Questions
### Risks
| Risk | Impact | Mitigation |
|------|--------|------------|
| Sidecar proxying adds latency | Slower page loads | Labels are small payloads; single DB query per label is fast |
| Frontend needs URL changes | Breaks if not updated | Do frontend change alongside sidecar deploy |
| Photo count queries on every label request | DB load | Cache results for 30s in the sidecar |
| PhotoPrism's label `PhotoCount` is stale | Mismatch with actual count | Acceptable — PhotoPrism's count is already cached |
| Review/archive fix depends on how PhotoPrism handles `path:` operator | Photos in subdirs missed | Use Variant B (post-filter by path prefix) |
### Open Questions
- **Q1**: For review/archive — is the user seeing dtoro's photos in the *grid* or only the *sidebar counts*? Need to verify actual API response vs what the frontend renders.
- **Q2**: What's the performance impact of running `SELECT COUNT(*) FROM photos_labels ... JOIN photos ...` for every label in the response? (Labels list is typically short, < 100)
- **Q3**: Does the frontend cache the label response aggressively? Need to invalidate cache on user switch.
- **Q4**: For the `path:` operator — does it do exact match or LIKE? From source: `WHERE photos.photo_path = ?` — exact match only.
### Tradeoffs
- **Sidecar proxy vs. frontend-only**: Proxy centralizes logic but adds network hop. Frontend-only is faster but more complex (every route needs path filtering).
- **Label count accuracy**: Recalculated per-user counts will differ from the library-wide counts. This is intentional — labels are scoped now.
- **Sidecar vs. patching PhotoPrism**: Sidecar approach is non-invasive (no fork/build of PP). PhotoPrism patch would be cleaner but requires maintaining a fork.
## 8. Recommendation
1. **Build the labels proxy** (Phase 1) — it directly solves the label isolation problem and can be done with existing sidecar infrastructure
2. **Investigate review/archive leak** first — run the actual API query as muli to confirm whether the photos search is actually scoped. The code analysis says it should be, but the user reports otherwise. If confirmed as a real leak, implement the sidecar photos proxy (Phase 2)
3. **Sidebar counts** (Phase 3) — lower priority, can be done after labels and review/archive are fixed
Before building, confirm with the user whether they see cross-user photos in the actual grid or only in the sidebar counts for review/archive.

View File

@@ -0,0 +1,227 @@
# Plan: Fix remaining user isolation issues — 404 errors and folder tree
**Date:** 2026-06-06
**Author:** Hermes Agent
**Status:** Draft
---
## 1. Goal
Fix the remaining issues after deploying the sidecar scoping proxy:
1. **404 on photo grid** — "Request failed with status code 404" in private window
2. **Folder tree shows other users** — on first load, the library tree lists other users' folders; a refresh fixes it
## 2. Current Context
### What's deployed
| Component | Status |
|-----------|--------|
| Sidecar labels proxy (`/api/sidecar/labels`) | ✅ Working |
| Sidecar counts proxy (`/api/sidecar/counts`) | ✅ Working |
| Sidecar timeline proxy (`/api/sidecar/timeline`) | ✅ Working through Caddy |
| Caddy fallback for `/api/v1/api/sidecar/*` | ✅ Working |
| Frontend rebuild with `sidecar` axios instance | ✅ Built and deployed |
### Verified working via Caddy
```bash
# Through public URL with valid admin token
curl https://photos.hubris.network/api/sidecar/timeline?count=1 → HTTP 200
curl https://photos.hubris.network/api/v1/photos?count=1 → HTTP 200
```
Both endpoints return 200 when tested directly through Caddy with a valid token.
### Reported issues
1. **404 on photo grid** — even in private window (no cache interference)
2. **Folder tree shows other users' folders** on first load, fixed by refresh
## 3. Root Cause Analysis
### Issue 1: 404 on photo grid
The `sidecar` axios instance (`baseURL: ''`) is missing the **response interceptor** that:
- Handles 401 → clears session → redirects to login
- Re-throws with meaningful error message
The `http` instance (for `/api/v1` endpoints) has this interceptor. Without it on `sidecar`:
- If the sidecar returns a non-2xx (401, 502 from upstream PP failure, etc.), axios throws a raw error
- The TanStack Query error boundary catches it and shows "Request failed with status code <status>"
- Very likely the sidecar is returning 401 on some calls (token expired / session not yet established) and the error message might show 404 because Caddy's catch-all returns 404 when a matcher doesn't find a route
**Hypothesis:** During OIDC login flow, the frontend may make some sidecar calls BEFORE the session is fully established (token loaded into `session.accessToken`). The `sidecar` interceptor checks `session.accessToken` but it might be null. Then the request to `/api/sidecar/timeline` has no auth header → sidecar returns 401 → no response interceptor → raw error.
**Fix:** Add the same 401 → login redirect interceptor to the `sidecar` instance.
### Issue 2: Folder tree shows other users
`listFolders()` calls `http.get('/folders/originals')` which hits PhotoPrism directly. PhotoPrism returns **all folders across the library** regardless of user. The frontend then filters by `userBasePath()` on the result:
```typescript
const bp = userBasePath();
if (bp === '') return folders; // On first load, bp might be empty!
return folders.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
```
On first load, `userBasePath()` returns `""` because:
1. The session data is loaded asynchronously
2. `session.user.BasePath` might not yet be populated when `listFolders` fires
3. The TanStack Query cache from a previous session might still have old data
After a refresh, the session is fully loaded, and `userBasePath()` returns the correct value.
A secondary issue: the `http` interceptor's 401 handler clears the session on 401. If the session expires during the app's lifetime, all subsequent requests fail with 401.
## 4. Proposed Approach
### Phase 1: Fix 404 — add response interceptor to sidecar
**File:** `web/src/lib/services/photoprism.ts`
Add the same 401 → login redirect interceptor to `sidecar` as already exists on `http`:
```typescript
sidecar.interceptors.response.use(
(r) => r,
(err: AxiosError) => {
if (err.response?.status === 401 && browser) {
clearSession();
const url = err.config?.url ?? '';
if (!url.endsWith('/session')) {
void goto('/login', { replaceState: true });
}
}
return Promise.reject(err);
}
);
```
### Phase 2: Fix folder tree — sidecar folder proxy
**File:** `sidecar/handlers_folders.go` (new)
Add a sidecar endpoint that proxies `/folders/originals` and post-filters by BasePath:
```
GET /api/sidecar/folders → proxies to GET /api/v1/folders/originals
→ removes folders not under user's base_path
→ returns filtered list
```
This avoids the timing issue entirely by filtering on the server side.
**Alternative (simpler):** Fix the frontend timing issue by ensuring `listFolders` doesn't fire until the session is ready.
### Phase 3: Change folder tree in frontend
**File:** `web/src/lib/services/photoprism.ts`
Change `listFolders()` to use `sidecar` instance and call `/api/sidecar/folders`:
```typescript
export async function listFolders(): Promise<PpFolder[]> {
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
'/api/sidecar/folders',
{ params: { recursive: true, uncached: true, files: false } }
);
const bp = userBasePath();
const folders = data.folders ?? [];
if (bp === '') return folders;
return folders
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
.map((f) => ({ ...f, Path: toUserPath(f.Path) }));
}
```
## 5. Step-by-step Plan
### Step 1: Add sidecar response interceptor
1. Edit `web/src/lib/services/photoprism.ts`
2. Add the 401-handling response interceptor to the `sidecar` instance
3. The interceptor mirrors the existing `http` response interceptor exactly
### Step 2: Rebuild frontend
```bash
cd /opt/mule-image/web && npm run build
```
### Step 3: (Optional) Add sidecar folder proxy
1. New file `sidecar/handlers_folders_proxy.go`
2. Handler similar to `handlePhotos` — proxies to `/api/v1/folders/originals`, post-filters by `Path` prefix
3. Wire route in `main.go`: `auth.GET("/folders", handleFoldersProxy(pp))`
4. Build Docker image, restart sidecar
### Step 4: Update listFolders to use sidecar
1. Change `listFolders()` to use `sidecar` instance
2. Call `/api/sidecar/folders` instead of `/folders/originals`
### Step 5: Rebuild + validate
```bash
# Rebuild frontend
cd /opt/mule-image/web && npm run build
# Test through Caddy
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1" \
-H "X-Auth-Token: <token>" | head -c 200
# Verify folders
curl -s "https://photos.hubris.network/api/sidecar/folders" \
-H "X-Auth-Token: <token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(json.dumps(d[:3],indent=2))"
```
### Step 6: Commit
```bash
git add -A && git commit -m "fix: add sidecar response interceptor + folder proxy" && git push
```
## 6. Files Likely to Change
| File | Change |
|------|--------|
| `web/src/lib/services/photoprism.ts` | Add response interceptor to sidecar instance; change listFolders URL |
| `sidecar/handlers_folders_proxy.go` | **New** — folder proxy handler |
| `sidecar/main.go` | Wire folder proxy route |
## 7. Tests & Validation
**Manual:**
1. Open private window → navigate to photos.hubris.network
2. Log in as muli via Authentik OIDC
3. Verify photo grid loads without 404
4. Verify folder tree shows only muli's folders
5. Switch to dtoro account → verify folders/timeline scoped to dtoro
**API tests:**
```bash
# Sidecar timeline (no token → 401 redirect)
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1"
# Sidecar folders
curl -s "https://photos.hubris.network/api/sidecar/folders"
```
## 8. Risks & Open Questions
### Risks
| Risk | Impact | Mitigation |
|------|--------|------------|
| Sidecar returns 401 during OIDC login flow before session is ready | 404 showing instead of graceful redirect | Add response interceptor in Phase 1 |
| Folder proxy adds latency | Slower folder tree loading | Minimal — single proxy call, same as PP direct |
| `userBasePath()` timing issue in listFolders persists even with sidecar | Folder tree still shows wrong folders on first load | Sidecar filter is server-side → no timing dependency |
### Open Questions
- **Q1**: Are there other API calls that bypass the `sidecar` instance and might also be unscoped? (e.g., `listSubjects`, `listGeo`, etc.)
- **Q2**: Does the sidecar need a folder proxy, or is the timing fix sufficient? The timing fix (delaying `listFolders` until session is ready) is simpler but fragile.
- **Q3**: Could the 404 be from Caddy's catch-all returning 404 when the sidecar isn't reachable? The Caddy fallback timeout for the sidecar might need tuning.

230
README.md
View File

@@ -1,128 +1,140 @@
# Mulita - Self-Hosted Photo Management Application
# mule-image
A self-hosted, Docker-deployed photo management application inspired by Lightroom's workflow. Mulita provides a fast, keyboard-driven interface to browse, organize, tag, and manage your photo library.
## Features
- **Photo Organization**: Browse photos in a timeline view with virtual scrolling for performance
- **Thumbnail Generation**: Automatic thumbnail generation for all photo formats including RAW
- **Metadata Extraction**: Full EXIF/XMP metadata extraction and search
- **Keyboard Shortcuts**: Lightroom-style keyboard navigation and actions
- **File Support**: JPEG, PNG, RAW formats (CR2, CR3, NEF, ARW, etc.), HEIC/HEIF, and videos
- **Heaps**: Temporary collections for organizing photos
- **Tags & Ratings**: Organize with tags, star ratings, and color labels
- **Dark Mode**: Photography-optimized dark interface
## Tech Stack
### Backend
- Python 3.12 with FastAPI
- SQLite with SQLAlchemy (async)
- Celery + Redis for background tasks
- pyvips for fast thumbnail generation
- ExifTool for metadata extraction
### Frontend
- React 18 with TypeScript
- Vite for fast development
- TanStack Query for data fetching
- TanStack Virtual for virtualized scrolling
- Tailwind CSS for styling
- Zustand for state management
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Photo directories to mount
### Setup
1. Clone the repository:
```bash
git clone <repository-url>
cd muleimage
```
2. Configure your photo directories in `.env`:
```bash
# Edit .env file
PHOTO_DIRS=/path/to/your/photos
```
3. Start the application:
```bash
docker-compose up -d
```
4. Access the application at `http://localhost:3000`
Self-hosted photo management built on top of [PhotoPrism][pp]. A SvelteKit
frontend ([`web/`](web/)) plus a small Go service ([`sidecar/`](sidecar/))
fill in the keyboard-driven UI and the file/folder/mark endpoints
PhotoPrism's REST API does not expose. PhotoPrism itself handles
indexing, originals, thumbnails, and the database; we never re-implement
those.
## Architecture
The application consists of 5 Docker services:
- **frontend**: React SPA served by Nginx
- **backend**: FastAPI REST API
- **worker**: Celery workers for background tasks
- **redis**: Message broker for Celery
- **db**: SQLite database (file-based)
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `←` `→` `↑` `↓` | Navigate photos |
| `Space` | Quick preview |
| `Enter` | Open loupe view |
| `P` | Pick photo |
| `X` | Reject photo |
| `1-5` | Set star rating |
| `Tab` | Toggle left sidebar |
| `I` | Toggle metadata panel |
| `G` | Grid view |
| `E` | Loupe view |
| `Delete` | Move to trash |
## Development
### Backend Development
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload
```text
┌──────────────────┐ /api/v1/* ┌──────────────┐
│ SvelteKit web/ │ ───────────────▶ │ photoprism │ ──▶ mariadb
│ (Vite : 5173) │ /api/sidecar/* │ :2342 │
│ │ ─────────┐ └──────────────┘
└──────────────────┘ ▼
┌──────────────┐
│ sidecar │ ──▶ mariadb (mule_sidecar.*)
│ :8000 │ ──▶ originals FS (rename / folders / dups)
└──────────────┘
```
### Frontend Development
Three compose services — `mariadb`, `photoprism`, `sidecar` — plus the
SvelteKit `web/` app served separately. PhotoPrism's port `2342` is
**bound to `127.0.0.1` only**; it isn't a user-facing surface. The
SvelteKit app is.
What the sidecar adds on top of PhotoPrism (full list in
[`sidecar/README.md`](sidecar/README.md)):
- Per-photo marks (rating + color) persisted to `mule_sidecar.marks`
- File rename + folder create/rename/delete with PhotoPrism reindex
- Heap (album) → folder conversion
- Perceptual-hash duplicate scan + archive
## Quick start
```bash
cd frontend
cp .env.example .env
# edit .env: set PHOTO_DIRS to the host path holding your library
# rotate PP_ADMIN_PASSWORD, PP_DB_PASSWORD, PP_DB_ROOT_PASSWORD
# before any non-local deployment.
podman-compose --env-file .env \
-f docker-compose.yml \
-f docker-compose.podman.yml \
up -d
```
Then serve the frontend. For local use the simplest path is the Vite
dev server:
```bash
cd web
npm install
npm run dev
# open http://localhost:5173
```
For a static deployment, `npm run build` produces a bundle under
`web/build/` that any static file host (nginx, Caddy, GitHub Pages-style)
can serve. Reverse-proxy `/api/v1/*` to `http://127.0.0.1:2342` and
`/api/sidecar/*` to `http://127.0.0.1:8000`.
PhotoPrism's own UI is still reachable from the host at
`http://127.0.0.1:2342` if you need admin features (user management,
settings) — set up an SSH tunnel from your laptop if the server is
remote.
## Configuration
Edit `mulita.yml` to configure:
- Source photo directories
- Thumbnail sizes and quality
- Scanner settings
- Performance tuning
All knobs live in [`.env.example`](.env.example). The required ones:
## Performance
| Variable | Notes |
|----------------------|-----------------------------------------------------------------------------------------------|
| `PHOTO_DIRS` | Host path mounted at `/photoprism/originals`. The library. |
| `PP_ADMIN_PASSWORD` | First-boot admin password. Rotate. |
| `PP_DB_PASSWORD` | MariaDB password for the `photoprism` user. Rotate. |
| `PP_DB_ROOT_PASSWORD`| MariaDB root password. Rotate. |
| `PP_UID` / `PP_GID` | Host UID/GID that owns `PHOTO_DIRS`. PhotoPrism + sidecar drop to this user inside. |
| `PP_PORT` | Loopback host port for PhotoPrism (default `2342`). |
| `PP_ORIGINALS_MODE` | `rw` (default) or `ro` — see [Read-only libraries](#read-only-libraries). |
| `SIDECAR_PORT` | Loopback host port for the sidecar (default `8000`). |
- Handles 100,000+ photos efficiently
- Virtual scrolling for smooth timeline navigation
- Thumbnail generation at 10+ photos/second
- SQLite FTS5 for fast full-text search
Sidecar-specific env (DB DSN, `USER_BASEPATHS`, etc.) is documented in
[`sidecar/README.md`](sidecar/README.md).
## Future Features (Phase 2)
## Read-only libraries
- AI-powered scene classification
- Face detection and clustering
- Smart albums
- Duplicate detection
- Export presets
- Multi-user support
The default originals mount is `:rw` because file operations (rename,
folder mutations, duplicate archive, heap convert) need to mutate the
filesystem. To run against a read-only archive, set
`PP_ORIGINALS_MODE=ro` in `.env`. Browsing, marks, ratings, and color
labels still work; the following sidecar endpoints return an OS error:
## License
- `POST /api/sidecar/files/:uid/rename`
- `POST /api/sidecar/folders` / `:rel/rename` / `DELETE /:rel`
- `POST /api/sidecar/albums/:uid/convert`
- `POST /api/sidecar/duplicates/archive`
MIT
PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by
`PP_READONLY` and gates its own backwrite / import paths.
## Dev iteration loop
For fast iteration on the sidecar without rebuilding its image on every
change, run it as a host process — bring up just `mariadb` and
`photoprism` from compose, then build and run the Go binary locally.
Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-host-build).
## Layout
```text
.
├── docker-compose.yml base stack: mariadb + photoprism + sidecar
├── docker-compose.podman.yml rootless-podman overlay (keep-id mapping)
├── docker-compose.gpu.yml opt-in VA-API GPU passthrough overlay
├── .env.example required env vars (copy to .env)
├── mariadb/init/ first-boot SQL: creates mule_sidecar DB + user
├── pp/ PhotoPrism bind-mounted state (storage, import)
├── sidecar/ Go service — see sidecar/README.md
└── web/ SvelteKit frontend
```
## GPU video acceleration (optional)
Hosts with a VA-API-capable GPU (Intel iGPU, AMD APU, etc.) can layer
[`docker-compose.gpu.yml`](docker-compose.gpu.yml) to hand `/dev/dri/*`
to PhotoPrism and switch ffmpeg to hardware encode/decode — a large
perf win for video thumbnails and HEVC→H.264 transcodes:
```bash
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
```
Set `PP_FFMPEG_ENCODER=vaapi` in `.env` (default for the overlay). Verify
with `docker exec pp-app photoprism show config | grep -i ffmpeg`.
[pp]: https://photoprism.app/

View File

@@ -1,39 +0,0 @@
FROM python:3.12-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
# Build dependencies
gcc \
g++ \
make \
# Image processing libraries
libvips42 \
libvips-dev \
# ExifTool for metadata extraction
libimage-exiftool-perl \
# FFmpeg for video processing
ffmpeg \
# Git for some Python packages
git \
# PostgreSQL client (for potential future use)
postgresql-client \
# Clean up
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create necessary directories
RUN mkdir -p /data/thumbs /data/db /data/trash /app/config
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -1,130 +0,0 @@
"""
Application configuration using Pydantic Settings
"""
from pydantic_settings import BaseSettings
from pydantic import BaseModel, Field
from typing import List, Optional
import os
import yaml
from pathlib import Path
class ThumbnailSettings(BaseModel):
"""Thumbnail generation settings"""
small: int = 240
medium: int = 640
large: int = 1280
quality: int = 85
format: str = "webp"
class ScannerSettings(BaseModel):
"""File scanner settings"""
watch: bool = True
initial_scan_on_start: bool = True
batch_size: int = 100
concurrent_workers: int = 4
class SourceRoot(BaseModel):
"""Source root directory configuration"""
name: str
path: str
class TrashSettings(BaseModel):
"""Trash settings"""
path: str = "/data/trash"
auto_empty_days: Optional[int] = 30
class PerformanceSettings(BaseModel):
"""Performance tuning settings"""
max_concurrent_thumbnails: int = 10
cache_ttl: int = 3600
db_pool_size: int = 20
db_pool_recycle: int = 3600
class MulitaConfig(BaseModel):
"""Main configuration from YAML file"""
source_roots: List[SourceRoot] = []
thumbnails: ThumbnailSettings = ThumbnailSettings()
scanner: ScannerSettings = ScannerSettings()
trash: TrashSettings = TrashSettings()
performance: PerformanceSettings = PerformanceSettings()
class Settings(BaseSettings):
"""Application settings"""
# Database
database_url: str = Field(
default="sqlite+aiosqlite:///data/db/mulita.db",
env="DATABASE_URL"
)
# Redis
redis_url: str = Field(
default="redis://localhost:6379",
env="REDIS_URL"
)
# Celery
celery_broker_url: str = Field(
default="redis://localhost:6379",
env="CELERY_BROKER_URL"
)
celery_result_backend: str = Field(
default="redis://localhost:6379",
env="CELERY_RESULT_BACKEND"
)
# Photo directories
photo_dirs: str = Field(
default="/photos",
env="PHOTO_DIRS"
)
# API settings
api_host: str = Field(default="0.0.0.0", env="API_HOST")
api_port: int = Field(default=8000, env="API_PORT")
# App configuration from YAML
_config: Optional[MulitaConfig] = None
@property
def config(self) -> MulitaConfig:
"""Load configuration from YAML file"""
if self._config is None:
config_path = Path("/app/config/mulita.yml")
if not config_path.exists():
config_path = Path("mulita.yml")
if config_path.exists():
with open(config_path, "r") as f:
config_data = yaml.safe_load(f)
self._config = MulitaConfig(**config_data)
else:
self._config = MulitaConfig()
return self._config
@property
def thumbnails(self) -> ThumbnailSettings:
return self.config.thumbnails
@property
def scanner(self) -> ScannerSettings:
return self.config.scanner
@property
def trash(self) -> TrashSettings:
return self.config.trash
@property
def performance(self) -> PerformanceSettings:
return self.config.performance
@property
def source_roots(self) -> List[SourceRoot]:
return self.config.source_roots
class Config:
env_file = ".env"
case_sensitive = False
# Global settings instance
settings = Settings()

View File

@@ -1,89 +0,0 @@
"""
Database configuration and session management
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import declarative_base
from sqlalchemy import event, text
import logging
import os
from pathlib import Path
from app.config import settings
logger = logging.getLogger(__name__)
# Create database directory if it doesn't exist
db_path = Path(settings.database_url.replace("sqlite+aiosqlite:///", ""))
db_path.parent.mkdir(parents=True, exist_ok=True)
# Create async engine
# SQLite doesn't support pool configuration
if "sqlite" in settings.database_url:
engine = create_async_engine(
settings.database_url,
echo=False, # Set to True for SQL debugging
connect_args={
"check_same_thread": False, # SQLite specific
"timeout": 30
}
)
else:
engine = create_async_engine(
settings.database_url,
echo=False, # Set to True for SQL debugging
pool_size=settings.performance.db_pool_size,
pool_recycle=settings.performance.db_pool_recycle
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
# Base class for models
Base = declarative_base()
async def get_db() -> AsyncSession:
"""Dependency to get database session"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def init_db():
"""Initialize database, create tables if they don't exist"""
async with engine.begin() as conn:
# Import all models to register them with Base
from app.models import Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
# Create all tables
await conn.run_sync(Base.metadata.create_all)
# Enable WAL mode for SQLite (better concurrency)
if "sqlite" in settings.database_url:
await conn.execute(text("PRAGMA journal_mode=WAL"))
await conn.execute(text("PRAGMA synchronous=NORMAL"))
await conn.execute(text("PRAGMA cache_size=10000"))
await conn.execute(text("PRAGMA temp_store=MEMORY"))
logger.info("Database initialized successfully")
async def create_fts_table():
"""Create Full-Text Search table for SQLite"""
if "sqlite" in settings.database_url:
async with engine.begin() as conn:
# Create FTS5 virtual table for full-text search
await conn.execute(text("""
CREATE VIRTUAL TABLE IF NOT EXISTS photos_fts USING fts5(
photo_id UNINDEXED,
filename,
user_title,
user_notes,
exif_text,
tokenize='unicode61'
)
"""))
logger.info("FTS5 table created successfully")

View File

@@ -1,82 +0,0 @@
"""
Mulita - Photo Management Application
Main FastAPI application entry point
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
import logging
import os
from app.config import settings
from app.database import init_db
from app.routers import photos, folders, heaps, tags, trash, library
from app.services.scanner import start_initial_scan
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifecycle"""
logger.info("Starting Mulita application...")
# Initialize database
await init_db()
# Start initial scan if configured
if settings.scanner.initial_scan_on_start:
logger.info("Starting initial library scan...")
await start_initial_scan()
yield
logger.info("Shutting down Mulita application...")
# Create FastAPI app
app = FastAPI(
title="Mulita Photo Management API",
description="Self-hosted photo management application inspired by Lightroom",
version="1.0.0",
lifespan=lifespan
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:5173"], # Frontend URLs
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files for serving thumbnails (with X-Accel-Redirect support)
if os.path.exists("/data/thumbs"):
app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="thumbs")
# Include routers
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"])
app.include_router(trash.router, prefix="/api/v1/trash", tags=["trash"])
app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
@app.get("/")
async def root():
"""Root endpoint"""
return {
"name": "Mulita Photo Management API",
"version": "1.0.0",
"status": "running"
}
@app.get("/health")
async def health_check():
"""Health check endpoint for Docker"""
return {"status": "healthy"}

View File

@@ -1,19 +0,0 @@
"""
Database models for Mulita
"""
from app.models.photos import Photo
from app.models.folders import Folder, SourceRoot
from app.models.tags import Tag, PhotoTag
from app.models.heaps import Heap, HeapPhoto
from app.models.embeddings import Embedding
__all__ = [
'Photo',
'Folder',
'SourceRoot',
'Tag',
'PhotoTag',
'Heap',
'HeapPhoto',
'Embedding'
]

View File

@@ -1,17 +0,0 @@
"""
Embedding model definition (placeholder for AI features)
"""
from sqlalchemy import Column, String, ForeignKey, LargeBinary
import uuid
from app.database import Base
class Embedding(Base):
"""
Placeholder table for future AI embeddings (CLIP, face recognition, etc.)
"""
__tablename__ = 'embeddings'
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True)
model = Column(String) # e.g., 'clip-vit-b32', 'face-recognition', etc.
vector = Column(LargeBinary) # raw float32 bytes for embedding vector

View File

@@ -1,43 +0,0 @@
"""
Folder and SourceRoot model definitions
"""
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Index
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
import uuid
from app.database import Base
class SourceRoot(Base):
__tablename__ = 'source_roots'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
path = Column(String, unique=True, nullable=False)
is_active = Column(Boolean, default=True)
added_at = Column(DateTime, server_default=func.now())
# Relationships
folders = relationship("Folder", back_populates="source_root")
class Folder(Base):
__tablename__ = 'folders'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
path = Column(String, unique=True, nullable=False)
parent_id = Column(String, ForeignKey('folders.id'))
source_root_id = Column(String, ForeignKey('source_roots.id'))
photo_count = Column(Integer, default=0)
last_scanned = Column(DateTime)
# Relationships
source_root = relationship("SourceRoot", back_populates="folders")
photos = relationship("Photo", backref="folder")
# Indexes
__table_args__ = (
Index('ix_folders_path', 'path'),
Index('ix_folders_parent_id', 'parent_id'),
Index('ix_folders_source_root_id', 'source_root_id'),
)

View File

@@ -1,37 +0,0 @@
"""
Heap model definitions
"""
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Table, Index
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
import uuid
from app.database import Base
# Association table for many-to-many relationship with additional fields
heap_photos = Table(
'heap_photos',
Base.metadata,
Column('heap_id', String, ForeignKey('heaps.id', ondelete='CASCADE'), primary_key=True),
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
Column('added_at', DateTime, server_default=func.now()),
Column('sort_order', Integer, default=0),
Index('ix_heap_photos_heap_id', 'heap_id'),
Index('ix_heap_photos_photo_id', 'photo_id'),
)
class Heap(Base):
__tablename__ = 'heaps'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
is_active = Column(Boolean, default=False) # For active heap feature
# Relationships
photos = relationship("Photo", secondary=heap_photos, backref="heaps")
class HeapPhoto:
"""Helper class for heap-photo associations (not a table model)"""
pass

View File

@@ -1,75 +0,0 @@
"""
Photo model definition
"""
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Index
from sqlalchemy.sql import func
from datetime import datetime
import uuid
from app.database import Base
class Photo(Base):
__tablename__ = 'photos'
# Primary key
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
# File information
filepath = Column(String, unique=True, nullable=False)
filename = Column(String, nullable=False)
folder_id = Column(String, ForeignKey('folders.id'))
file_hash = Column(String, index=True) # SHA-256 hash for duplicate detection
# Media information
media_type = Column(String, nullable=False) # 'photo' | 'video' | 'raw' | 'heic'
original_format = Column(String) # 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.
width = Column(Integer)
height = Column(Integer)
file_size = Column(Integer)
# Timestamps
taken_at = Column(DateTime) # from EXIF DateTimeOriginal, fallback to file mtime
taken_at_source = Column(String) # 'exif' | 'filesystem' | 'manual'
added_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
# Trash status
is_trashed = Column(Boolean, default=False)
trashed_at = Column(DateTime)
# Thumbnail paths
thumb_small = Column(String) # path to 240px thumb
thumb_medium = Column(String) # path to 640px thumb
thumb_large = Column(String) # path to 1280px thumb
# Processing status
processing_status = Column(String, default='pending') # 'pending' | 'processing' | 'completed' | 'failed'
processing_error = Column(Text)
# Metadata
exif_json = Column(Text) # full EXIF/XMP blob as JSON
# User-editable fields
user_title = Column(String)
user_notes = Column(Text)
rating = Column(Integer, default=0) # 0-5 stars
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
is_picked = Column(Boolean, default=False)
is_rejected = Column(Boolean, default=False)
# Duplicate detection
is_duplicate = Column(Boolean, default=False)
# Live photo support
live_photo_video_id = Column(String, ForeignKey('photos.id'))
# Indexes for performance
__table_args__ = (
Index('ix_photos_taken_at', 'taken_at'),
Index('ix_photos_folder_id', 'folder_id'),
Index('ix_photos_is_trashed', 'is_trashed'),
Index('ix_photos_rating', 'rating'),
Index('ix_photos_color_label', 'color_label'),
Index('ix_photos_media_type', 'media_type'),
Index('ix_photos_processing_status', 'processing_status'),
)

View File

@@ -1,32 +0,0 @@
"""
Tag model definitions
"""
from sqlalchemy import Column, String, ForeignKey, Table, Index
from sqlalchemy.orm import relationship
import uuid
from app.database import Base
# Association table for many-to-many relationship
photo_tags = Table(
'photo_tags',
Base.metadata,
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
Column('tag_id', String, ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True),
Index('ix_photo_tags_photo_id', 'photo_id'),
Index('ix_photo_tags_tag_id', 'tag_id'),
)
class Tag(Base):
__tablename__ = 'tags'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, unique=True, nullable=False, index=True)
color = Column(String) # Hex color code for UI display
# Relationships
photos = relationship("Photo", secondary=photo_tags, backref="tags")
class PhotoTag:
"""Helper class for photo-tag associations (not a table model)"""
pass

View File

@@ -1,99 +0,0 @@
"""
Folders API router
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
from pydantic import BaseModel
import os
import uuid
from app.database import get_db
from app.models import Folder, SourceRoot
router = APIRouter()
class FolderCreate(BaseModel):
path: str
recursive: bool = True
watch: bool = False
class FolderResponse(BaseModel):
id: str
name: str
path: str
photo_count: int
@router.get("")
async def get_folders(db: AsyncSession = Depends(get_db)):
"""Get all source folders"""
# Get source roots instead of regular folders
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True))
source_roots = result.scalars().all()
folders_list = []
for root in source_roots:
# Get photo count for this source root
folder_result = await db.execute(
select(Folder).where(Folder.source_root_id == root.id)
)
folders = folder_result.scalars().all()
photo_count = sum(f.photo_count for f in folders)
folders_list.append({
"id": root.id,
"name": root.name or os.path.basename(root.path),
"path": root.path,
"photo_count": photo_count
})
return {"folders": folders_list}
@router.post("")
async def create_folder(folder: FolderCreate, db: AsyncSession = Depends(get_db)):
"""Add a new source folder"""
# Check if path exists
if not os.path.exists(folder.path):
raise HTTPException(status_code=400, detail=f"Path does not exist: {folder.path}")
# Check if path is already added
result = await db.execute(select(SourceRoot).where(SourceRoot.path == folder.path))
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Path already added as source folder")
# Create source root
source_root = SourceRoot(
id=str(uuid.uuid4()),
name=os.path.basename(folder.path),
path=folder.path
)
db.add(source_root)
await db.commit()
# Automatically trigger a scan for the new folder
from app.tasks.celery import celery_app
celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
return {
"id": source_root.id,
"name": source_root.name,
"path": source_root.path,
"photo_count": 0
}
@router.post("/{folder_id}/scan")
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
"""Trigger manual re-scan of source root folder"""
from app.tasks.celery import celery_app
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
source_root = result.scalar_one_or_none()
if not source_root:
raise HTTPException(status_code=404, detail="Source folder not found")
# Queue scan task using the task name defined in the decorator
task = celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
return {"status": "success", "message": f"Scan queued for {source_root.path}", "task_id": task.id}

View File

@@ -1,27 +0,0 @@
"""
Heaps API router
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Heap
router = APIRouter()
@router.get("")
async def list_heaps(db: AsyncSession = Depends(get_db)):
"""List all heaps"""
result = await db.execute(select(Heap))
heaps = result.scalars().all()
return heaps
@router.post("")
async def create_heap(name: str, db: AsyncSession = Depends(get_db)):
"""Create a new heap"""
heap = Heap(name=name)
db.add(heap)
await db.commit()
await db.refresh(heap)
return heap

View File

@@ -1,72 +0,0 @@
"""
Library API router for stats and scanning
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Photo
router = APIRouter()
@router.get("/stats")
async def get_library_stats(db: AsyncSession = Depends(get_db)):
"""Get library statistics"""
# Count total photos
total_photos = await db.execute(
select(func.count(Photo.id)).where(Photo.media_type.in_(['photo', 'heic', 'raw']))
)
photo_count = total_photos.scalar()
# Count total videos
total_videos = await db.execute(
select(func.count(Photo.id)).where(Photo.media_type == 'video')
)
video_count = total_videos.scalar()
# Calculate total size
total_size = await db.execute(
select(func.sum(Photo.file_size))
)
size = total_size.scalar() or 0
return {
"total_photos": photo_count,
"total_videos": video_count,
"total_size": size,
"total_size_gb": round(size / (1024**3), 2) if size else 0
}
@router.post("/scan")
async def trigger_scan():
"""Trigger full library re-scan"""
from app.tasks.scan import scan_all_source_roots
scan_all_source_roots.delay()
return {"status": "success", "message": "Library scan started"}
@router.get("/scan/status")
async def get_scan_status(db: AsyncSession = Depends(get_db)):
"""Get current scan status"""
import redis
from app.config import settings
# Connect to Redis to get scan status
r = redis.Redis.from_url(settings.redis_url)
# Get scan status from Redis (set by worker tasks)
is_scanning = r.get('scan:active') == b'true'
current_folder = r.get('scan:current_folder')
processed_files = int(r.get('scan:processed_files') or 0)
total_files = int(r.get('scan:total_files') or 0)
errors = r.lrange('scan:errors', 0, -1)
return {
"is_scanning": is_scanning,
"current_folder": current_folder.decode() if current_folder else None,
"processed_files": processed_files,
"total_files": total_files,
"errors": [e.decode() for e in errors] if errors else []
}

View File

@@ -1,354 +0,0 @@
"""
Photos API router
"""
from typing import List, Optional, Dict, Any
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse
from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
import json
import os
import logging
logger = logging.getLogger(__name__)
from app.database import get_db
from app.models import Photo, Folder, Tag, PhotoTag
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
from app.config import settings
router = APIRouter()
@router.get("", response_model=PhotoListResponse)
async def list_photos(
q: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
folder_id: Optional[str] = None,
tag_ids: Optional[str] = None,
media_type: Optional[str] = None,
rating_min: Optional[int] = Query(None, ge=0, le=5),
rating_max: Optional[int] = Query(None, ge=0, le=5),
color_label: Optional[str] = None,
is_picked: Optional[bool] = None,
is_rejected: Optional[bool] = None,
is_trashed: Optional[bool] = False,
heap_id: Optional[str] = None,
sort: str = "taken_at",
order: str = "desc",
page: int = Query(1, ge=1),
per_page: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db)
):
"""List photos with filters and pagination"""
# Build query
query = select(Photo)
# Apply filters
filters = []
# Text search (would use FTS5 in production)
if q:
search_pattern = f"%{q}%"
filters.append(
or_(
Photo.filename.ilike(search_pattern),
Photo.user_title.ilike(search_pattern),
Photo.user_notes.ilike(search_pattern),
Photo.exif_json.ilike(search_pattern)
)
)
# Date range
if date_from:
filters.append(Photo.taken_at >= date_from)
if date_to:
filters.append(Photo.taken_at <= date_to)
# Folder filter
if folder_id:
filters.append(Photo.folder_id == folder_id)
# Media type filter
if media_type:
types = media_type.split(',')
filters.append(Photo.media_type.in_(types))
# Rating filter
if rating_min is not None:
filters.append(Photo.rating >= rating_min)
if rating_max is not None:
filters.append(Photo.rating <= rating_max)
# Color label filter
if color_label:
if color_label == 'none':
filters.append(Photo.color_label.is_(None))
else:
filters.append(Photo.color_label == color_label)
# Flag filters
if is_picked is not None:
filters.append(Photo.is_picked == is_picked)
if is_rejected is not None:
filters.append(Photo.is_rejected == is_rejected)
# Trash filter
filters.append(Photo.is_trashed == is_trashed)
# Apply all filters
if filters:
query = query.where(and_(*filters))
# Apply sorting
sort_column = getattr(Photo, sort, Photo.taken_at)
if order == "desc":
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column.asc())
# Count total results
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar()
# Apply pagination
offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page)
# Execute query
result = await db.execute(query)
photos = result.scalars().all()
# Convert to response
return PhotoListResponse(
photos=[PhotoResponse.from_orm(photo) for photo in photos],
total=total,
page=page,
per_page=per_page,
pages=(total + per_page - 1) // per_page
)
@router.get("/{photo_id}", response_model=PhotoResponse)
async def get_photo(
photo_id: str,
db: AsyncSession = Depends(get_db)
):
"""Get single photo with full EXIF and tags"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
return PhotoResponse.from_orm(photo)
@router.get("/{photo_id}/thumb/{size}")
async def get_thumbnail(
photo_id: str,
size: str,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""Serve thumbnail (with Nginx X-Accel-Redirect support)"""
if size not in ['small', 'medium', 'large']:
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
# Check if thumbnail exists, generate if not
thumb_dir = f"/data/thumbs/{photo_id}"
thumb_path = f"{thumb_dir}/{size}.webp"
if not os.path.exists(thumb_path):
# Generate thumbnail on demand
from app.tasks.thumbs import generate_thumbnails
generate_thumbnails.delay(photo_id)
# For now, return a placeholder or the original with reduced quality
if os.path.exists(photo.filepath):
from PIL import Image
try:
os.makedirs(thumb_dir, exist_ok=True)
img = Image.open(photo.filepath)
# Auto-rotate based on EXIF
from PIL import ExifTags
try:
for orientation in ExifTags.TAGS.keys():
if ExifTags.TAGS[orientation] == 'Orientation':
break
exif = img._getexif()
if exif is not None:
orient = exif.get(orientation)
if orient == 3:
img = img.rotate(180, expand=True)
elif orient == 6:
img = img.rotate(270, expand=True)
elif orient == 8:
img = img.rotate(90, expand=True)
except:
pass
# Generate thumbnail size
sizes = {'small': 150, 'medium': 400, 'large': 800}
target_size = sizes.get(size, 400)
img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
# Save as WebP
img.save(thumb_path, 'WEBP', quality=85, optimize=True)
except Exception as e:
logger.error(f"Error generating thumbnail: {e}")
raise HTTPException(status_code=404, detail="Could not generate thumbnail")
# Check if we're behind Nginx
if os.environ.get('USE_X_ACCEL_REDIRECT'):
# Use Nginx X-Accel-Redirect for better performance
response.headers['X-Accel-Redirect'] = f'/internal_thumbs/{photo_id}/{size}.webp'
response.headers['Content-Type'] = 'image/webp'
return Response()
else:
# Direct file serving for development
return FileResponse(thumb_path, media_type='image/webp')
@router.get("/{photo_id}/original")
async def get_original(
photo_id: str,
db: AsyncSession = Depends(get_db)
):
"""Serve original file for download"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
photo.filepath,
filename=photo.filename,
media_type='application/octet-stream'
)
@router.patch("/{photo_id}", response_model=PhotoResponse)
async def update_photo(
photo_id: str,
update: PhotoUpdate,
db: AsyncSession = Depends(get_db)
):
"""Update photo metadata"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
# Apply updates
update_data = update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(photo, field, value)
await db.commit()
await db.refresh(photo)
return PhotoResponse.from_orm(photo)
@router.delete("/{photo_id}")
async def trash_photo(
photo_id: str,
db: AsyncSession = Depends(get_db)
):
"""Move photo to trash"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
# Move file to trash directory
import shutil
trash_dir = f"{settings.trash.path}/{photo_id}"
os.makedirs(trash_dir, exist_ok=True)
trash_path = f"{trash_dir}/original{Path(photo.filepath).suffix}"
try:
shutil.move(photo.filepath, trash_path)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to move file: {e}")
# Update database
photo.is_trashed = True
photo.trashed_at = datetime.utcnow()
await db.commit()
return {"status": "success", "message": "Photo moved to trash"}
@router.post("/bulk")
async def bulk_action(
action: BulkAction,
db: AsyncSession = Depends(get_db)
):
"""Perform bulk actions on multiple photos"""
# Get photos
result = await db.execute(
select(Photo).where(Photo.id.in_(action.ids))
)
photos = result.scalars().all()
if not photos:
raise HTTPException(status_code=404, detail="No photos found")
# Perform action based on type
if action.action == 'trash':
for photo in photos:
photo.is_trashed = True
photo.trashed_at = datetime.utcnow()
elif action.action == 'restore':
for photo in photos:
photo.is_trashed = False
photo.trashed_at = None
elif action.action == 'set_rating':
for photo in photos:
photo.rating = action.value
elif action.action == 'set_color':
for photo in photos:
photo.color_label = action.value
elif action.action == 'pick':
for photo in photos:
photo.is_picked = True
photo.is_rejected = False
elif action.action == 'reject':
for photo in photos:
photo.is_rejected = True
photo.is_picked = False
else:
raise HTTPException(status_code=400, detail="Invalid action")
await db.commit()
return {
"status": "success",
"message": f"{action.action} applied to {len(photos)} photos"
}

View File

@@ -1,27 +0,0 @@
"""
Tags API router
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Tag
router = APIRouter()
@router.get("")
async def list_tags(db: AsyncSession = Depends(get_db)):
"""List all tags with usage counts"""
result = await db.execute(select(Tag))
tags = result.scalars().all()
return tags
@router.post("")
async def create_tag(name: str, color: str = None, db: AsyncSession = Depends(get_db)):
"""Create a new tag"""
tag = Tag(name=name, color=color)
db.add(tag)
await db.commit()
await db.refresh(tag)
return tag

View File

@@ -1,50 +0,0 @@
"""
Trash API router
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from app.database import get_db
from app.models import Photo
router = APIRouter()
@router.get("")
async def list_trashed(db: AsyncSession = Depends(get_db)):
"""List trashed photos"""
result = await db.execute(
select(Photo).where(Photo.is_trashed == True)
)
photos = result.scalars().all()
return photos
@router.post("/restore")
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
"""Restore photos from trash"""
result = await db.execute(
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_trashed == True))
)
photos = result.scalars().all()
for photo in photos:
photo.is_trashed = False
photo.trashed_at = None
await db.commit()
return {"status": "success", "restored": len(photos)}
@router.delete("/empty")
async def empty_trash(db: AsyncSession = Depends(get_db)):
"""Permanently delete all trashed photos"""
result = await db.execute(
select(Photo).where(Photo.is_trashed == True)
)
photos = result.scalars().all()
for photo in photos:
await db.delete(photo)
await db.commit()
return {"status": "success", "deleted": len(photos)}

View File

@@ -1,71 +0,0 @@
"""
Pydantic schemas for photos
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
class PhotoBase(BaseModel):
"""Base photo schema"""
filename: str
media_type: str
original_format: Optional[str] = None
width: Optional[int] = None
height: Optional[int] = None
file_size: Optional[int] = None
taken_at: Optional[datetime] = None
taken_at_source: Optional[str] = None
user_title: Optional[str] = None
user_notes: Optional[str] = None
rating: int = 0
color_label: Optional[str] = None
is_picked: bool = False
is_rejected: bool = False
class PhotoResponse(PhotoBase):
"""Photo response schema"""
id: str
filepath: str
folder_id: Optional[str] = None
file_hash: Optional[str] = None
added_at: datetime
updated_at: Optional[datetime] = None
is_trashed: bool = False
trashed_at: Optional[datetime] = None
thumb_small: Optional[str] = None
thumb_medium: Optional[str] = None
thumb_large: Optional[str] = None
processing_status: str = 'pending'
processing_error: Optional[str] = None
exif_json: Optional[str] = None
is_duplicate: bool = False
live_photo_video_id: Optional[str] = None
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading
class Config:
orm_mode = True
from_attributes = True
class PhotoUpdate(BaseModel):
"""Photo update schema"""
user_title: Optional[str] = None
user_notes: Optional[str] = None
rating: Optional[int] = Field(None, ge=0, le=5)
color_label: Optional[str] = None
is_picked: Optional[bool] = None
is_rejected: Optional[bool] = None
taken_at: Optional[datetime] = None
class PhotoListResponse(BaseModel):
"""Photo list response with pagination"""
photos: List[PhotoResponse]
total: int
page: int
per_page: int
pages: int
class BulkAction(BaseModel):
"""Bulk action on photos"""
ids: List[str]
action: str # 'trash', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick', 'reject'
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)

View File

@@ -1,183 +0,0 @@
"""
Metadata extraction service using ExifTool
"""
import json
import logging
import asyncio
from datetime import datetime
from typing import Dict, Optional
import subprocess
from pathlib import Path
from celery import shared_task
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import Photo
logger = logging.getLogger(__name__)
def parse_exif_datetime(date_str: str) -> Optional[datetime]:
"""Parse EXIF datetime string to Python datetime"""
if not date_str:
return None
# Common EXIF datetime formats
formats = [
"%Y:%m:%d %H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%Y:%m:%d %H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S%z"
]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
return None
def extract_key_metadata(exif_data: Dict) -> Dict:
"""Extract key metadata fields for FTS indexing"""
key_fields = []
# Camera information
if 'Make' in exif_data:
key_fields.append(exif_data['Make'])
if 'Model' in exif_data:
key_fields.append(exif_data['Model'])
if 'LensModel' in exif_data:
key_fields.append(exif_data['LensModel'])
# Location information
if 'GPSLatitude' in exif_data and 'GPSLongitude' in exif_data:
key_fields.append(f"GPS: {exif_data['GPSLatitude']}, {exif_data['GPSLongitude']}")
# IPTC/XMP keywords
if 'Keywords' in exif_data:
if isinstance(exif_data['Keywords'], list):
key_fields.extend(exif_data['Keywords'])
else:
key_fields.append(exif_data['Keywords'])
# Copyright and creator
if 'Copyright' in exif_data:
key_fields.append(exif_data['Copyright'])
if 'Creator' in exif_data:
key_fields.append(exif_data['Creator'])
if 'Artist' in exif_data:
key_fields.append(exif_data['Artist'])
return {
'exif_text': ' '.join(key_fields),
'camera_make': exif_data.get('Make'),
'camera_model': exif_data.get('Model'),
'lens_model': exif_data.get('LensModel'),
'gps_latitude': exif_data.get('GPSLatitude'),
'gps_longitude': exif_data.get('GPSLongitude'),
}
@shared_task(name='extract_metadata')
def extract_metadata(photo_id: str):
"""Extract metadata from a photo using ExifTool"""
return asyncio.run(_extract_metadata_async(photo_id))
async def _extract_metadata_async(photo_id: str):
"""Async implementation of metadata extraction"""
async with AsyncSessionLocal() as session:
try:
# Get photo from database
result = await session.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
logger.error(f"Photo not found: {photo_id}")
return {'status': 'error', 'message': 'Photo not found'}
# Check if file exists
if not Path(photo.filepath).exists():
logger.error(f"File not found: {photo.filepath}")
return {'status': 'error', 'message': 'File not found'}
# Run ExifTool to extract metadata
cmd = [
'exiftool',
'-j', # JSON output
'-G', # Group names
'-s', # Short output format
'-All', # All metadata
photo.filepath
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
logger.error(f"ExifTool error: {result.stderr}")
return {'status': 'error', 'message': result.stderr}
# Parse JSON output
metadata = json.loads(result.stdout)
if metadata and len(metadata) > 0:
exif_data = metadata[0]
# Store full metadata as JSON
photo.exif_json = json.dumps(exif_data)
# Extract taken_at date
date_fields = [
'EXIF:DateTimeOriginal',
'EXIF:CreateDate',
'QuickTime:MediaCreateDate',
'EXIF:ModifyDate'
]
for field in date_fields:
if field in exif_data:
taken_at = parse_exif_datetime(exif_data[field])
if taken_at:
photo.taken_at = taken_at
photo.taken_at_source = 'exif'
break
# Extract dimensions if not already set
if not photo.width:
photo.width = exif_data.get('EXIF:ImageWidth') or exif_data.get('File:ImageWidth')
if not photo.height:
photo.height = exif_data.get('EXIF:ImageHeight') or exif_data.get('File:ImageHeight')
# Extract and store key metadata for search
key_metadata = extract_key_metadata(exif_data)
# Update FTS table (would be done via trigger in production)
# For now, we'll store it in a comment
await session.commit()
logger.info(f"Metadata extracted for photo {photo_id}")
return {
'status': 'success',
'photo_id': photo_id,
'taken_at': photo.taken_at.isoformat() if photo.taken_at else None
}
except subprocess.TimeoutExpired:
logger.error(f"ExifTool timeout for {photo.filepath}")
return {'status': 'error', 'message': 'ExifTool timeout'}
except json.JSONDecodeError as e:
logger.error(f"Failed to parse ExifTool output: {e}")
return {'status': 'error', 'message': 'Invalid ExifTool output'}
except Exception as e:
logger.error(f"Error extracting metadata for {photo_id}: {e}")
return {'status': 'error', 'message': str(e)}

View File

@@ -1,22 +0,0 @@
"""
Scanner service for initial library scan
"""
import logging
from app.tasks.scan import scan_all_source_roots, watch_folders
from app.config import settings
logger = logging.getLogger(__name__)
async def start_initial_scan():
"""Start the initial library scan"""
try:
# Queue scan of all source roots
scan_all_source_roots.delay()
# Start folder watcher if configured
if settings.scanner.watch:
watch_folders.delay()
logger.info("Initial scan queued successfully")
except Exception as e:
logger.error(f"Failed to start initial scan: {e}")

View File

@@ -1,15 +0,0 @@
"""
Celery tasks module
"""
from app.tasks.celery import celery_app
from app.tasks.scan import scan_folder, scan_all_source_roots, watch_folders
from app.tasks.thumbs import generate_thumbnails, regenerate_all_thumbnails
__all__ = [
'celery_app',
'scan_folder',
'scan_all_source_roots',
'watch_folders',
'generate_thumbnails',
'regenerate_all_thumbnails'
]

View File

@@ -1,31 +0,0 @@
"""
Celery configuration and app initialization
"""
from celery import Celery
from app.config import settings
# Create Celery app
celery_app = Celery(
'mulita',
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
include=['app.tasks.scan', 'app.tasks.thumbs']
)
# Configure Celery
celery_app.conf.update(
task_serializer='json',
accept_content=['json'],
result_serializer='json',
timezone='UTC',
enable_utc=True,
task_routes={
'app.tasks.thumbs.*': {'queue': 'high'},
'app.tasks.scan.*': {'queue': 'low'},
},
task_default_queue='default',
task_default_exchange='default',
task_default_exchange_type='direct',
task_default_routing_key='default',
broker_connection_retry_on_startup=True,
)

View File

@@ -1,298 +0,0 @@
"""
Celery tasks for scanning folders and indexing photos
"""
import os
import hashlib
import asyncio
from pathlib import Path
from datetime import datetime
import logging
import json
from typing import List, Dict, Optional
from celery import shared_task
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import aiofiles
from app.database import AsyncSessionLocal
from app.models import Photo, Folder, SourceRoot
from app.config import settings
from app.tasks.thumbs import generate_thumbnails
from app.services.metadata import extract_metadata
logger = logging.getLogger(__name__)
# Supported file extensions
PHOTO_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.webp', '.bmp'}
RAW_EXTENSIONS = {'.cr2', '.cr3', '.nef', '.arw', '.raf', '.dng', '.orf', '.rw2', '.pef', '.srw'}
HEIC_EXTENSIONS = {'.heic', '.heif'}
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.avi', '.mkv', '.mts', '.m2ts', '.3gp', '.wmv', '.flv'}
SUPPORTED_EXTENSIONS = PHOTO_EXTENSIONS | RAW_EXTENSIONS | HEIC_EXTENSIONS | VIDEO_EXTENSIONS
def get_media_type(filepath: str) -> str:
"""Determine media type from file extension"""
ext = Path(filepath).suffix.lower()
if ext in PHOTO_EXTENSIONS:
return 'photo'
elif ext in RAW_EXTENSIONS:
return 'raw'
elif ext in HEIC_EXTENSIONS:
return 'heic'
elif ext in VIDEO_EXTENSIONS:
return 'video'
return 'unknown'
async def calculate_file_hash(filepath: str) -> str:
"""Calculate SHA-256 hash of a file"""
hash_sha256 = hashlib.sha256()
try:
async with aiofiles.open(filepath, 'rb') as f:
while chunk := await f.read(8192):
hash_sha256.update(chunk)
return hash_sha256.hexdigest()
except Exception as e:
logger.error(f"Error calculating hash for {filepath}: {e}")
return ""
@shared_task(bind=True, name='scan_folder')
def scan_folder(self, folder_path: str, source_root_id: Optional[str] = None):
"""
Scan a folder and index all photos/videos
"""
# Run async function in sync context
return asyncio.run(_scan_folder_async(folder_path, source_root_id, self))
async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], task):
"""Async implementation of folder scanning"""
logger.info(f"Starting scan of folder: {folder_path}")
async with AsyncSessionLocal() as session:
try:
# Get or create source root
if not source_root_id:
source_root = await get_or_create_source_root(session, folder_path)
source_root_id = source_root.id
# Walk the directory tree
total_files = 0
processed_files = 0
errors = []
for root, dirs, files in os.walk(folder_path):
# Get or create folder entry
folder = await get_or_create_folder(session, root, source_root_id)
# Filter supported files
supported_files = [f for f in files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS]
total_files += len(supported_files)
# Process files in batches
batch_size = settings.scanner.batch_size
for i in range(0, len(supported_files), batch_size):
batch = supported_files[i:i + batch_size]
for filename in batch:
filepath = os.path.join(root, filename)
try:
# Check if file already exists in database
existing = await session.execute(
select(Photo).where(Photo.filepath == filepath)
)
if existing.scalar_one_or_none():
logger.debug(f"File already indexed: {filepath}")
processed_files += 1
continue
# Get file stats
stat = os.stat(filepath)
# Calculate file hash for duplicate detection
file_hash = await calculate_file_hash(filepath)
# Check for duplicate by hash
duplicate = await session.execute(
select(Photo).where(Photo.file_hash == file_hash)
) if file_hash else None
# Create photo entry
photo = Photo(
filepath=filepath,
filename=filename,
folder_id=folder.id,
file_hash=file_hash,
media_type=get_media_type(filepath),
original_format=Path(filepath).suffix.upper()[1:],
file_size=stat.st_size,
taken_at=datetime.fromtimestamp(stat.st_mtime),
taken_at_source='filesystem',
is_duplicate=bool(duplicate.scalar_one_or_none() if duplicate else False),
processing_status='pending'
)
session.add(photo)
await session.flush() # Get the photo ID
# Queue thumbnail generation
generate_thumbnails.delay(photo.id)
# Queue metadata extraction
extract_metadata.delay(photo.id)
processed_files += 1
# Update progress
if processed_files % 10 == 0:
task.update_state(
state='PROGRESS',
meta={
'current': processed_files,
'total': total_files,
'folder': root
}
)
except Exception as e:
logger.error(f"Error processing file {filepath}: {e}")
errors.append({'file': filepath, 'error': str(e)})
continue
# Commit batch
await session.commit()
# Update folder scan timestamp
folder.last_scanned = datetime.utcnow()
folder.photo_count = processed_files
await session.commit()
logger.info(f"Scan complete. Processed {processed_files}/{total_files} files. Errors: {len(errors)}")
return {
'status': 'completed',
'processed': processed_files,
'total': total_files,
'errors': errors
}
except Exception as e:
logger.error(f"Scan failed: {e}")
await session.rollback()
raise
async def get_or_create_source_root(session: AsyncSession, path: str) -> SourceRoot:
"""Get or create a source root entry"""
from sqlalchemy import select
result = await session.execute(
select(SourceRoot).where(SourceRoot.path == path)
)
source_root = result.scalar_one_or_none()
if not source_root:
source_root = SourceRoot(
name=Path(path).name,
path=path
)
session.add(source_root)
await session.flush()
return source_root
async def get_or_create_folder(session: AsyncSession, path: str, source_root_id: str) -> Folder:
"""Get or create a folder entry"""
from sqlalchemy import select
result = await session.execute(
select(Folder).where(Folder.path == path)
)
folder = result.scalar_one_or_none()
if not folder:
parent_path = str(Path(path).parent)
parent = None
if parent_path != path: # Not root folder
parent_result = await session.execute(
select(Folder).where(Folder.path == parent_path)
)
parent = parent_result.scalar_one_or_none()
if parent:
parent_id = parent.id
else:
# Recursively create parent
parent = await get_or_create_folder(session, parent_path, source_root_id)
parent_id = parent.id
else:
parent_id = None
folder = Folder(
name=Path(path).name,
path=path,
parent_id=parent_id,
source_root_id=source_root_id
)
session.add(folder)
await session.flush()
return folder
@shared_task(name='scan_all_source_roots')
def scan_all_source_roots():
"""Scan all configured source roots"""
for source_root in settings.source_roots:
if os.path.exists(source_root.path):
scan_folder.delay(source_root.path)
else:
logger.warning(f"Source root path does not exist: {source_root.path}")
@shared_task(name='watch_folders')
def watch_folders():
"""
Watch folders for changes using watchfiles
This is a long-running task that monitors file system events
"""
from watchfiles import watch
paths = [sr.path for sr in settings.source_roots if os.path.exists(sr.path)]
if not paths:
logger.warning("No valid source roots to watch")
return
logger.info(f"Starting folder watcher for: {paths}")
for changes in watch(*paths):
for change_type, filepath in changes:
filepath = str(filepath)
# Check if it's a supported file type
if Path(filepath).suffix.lower() not in SUPPORTED_EXTENSIONS:
continue
if change_type == 'added' or change_type == 'modified':
# Queue scan for the parent folder
parent_dir = str(Path(filepath).parent)
scan_folder.delay(parent_dir)
logger.info(f"File {change_type}: {filepath}, queued scan for {parent_dir}")
elif change_type == 'deleted':
# Handle file deletion
asyncio.run(handle_file_deletion(filepath))
async def handle_file_deletion(filepath: str):
"""Handle deletion of a file from the filesystem"""
from sqlalchemy import select
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Photo).where(Photo.filepath == filepath)
)
photo = result.scalar_one_or_none()
if photo:
# Mark as missing or delete from database
photo.is_trashed = True
photo.trashed_at = datetime.utcnow()
await session.commit()
logger.info(f"Marked photo as trashed: {filepath}")

View File

@@ -1,283 +0,0 @@
"""
Celery tasks for thumbnail generation
"""
import os
import asyncio
from pathlib import Path
import logging
from typing import Tuple, Optional
import json
from celery import shared_task
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from PIL import Image
import imageio
from pillow_heif import register_heif_opener
import ffmpeg
# Try to import optional libraries
try:
import pyvips
PYVIPS_AVAILABLE = True
except ImportError:
PYVIPS_AVAILABLE = False
print("pyvips not available, using Pillow for image processing")
try:
import rawpy
RAWPY_AVAILABLE = True
except ImportError:
RAWPY_AVAILABLE = False
print("rawpy not available, using exiftool for RAW preview extraction")
from app.database import AsyncSessionLocal
from app.models import Photo
from app.config import settings
# Register HEIF opener with Pillow
register_heif_opener()
logger = logging.getLogger(__name__)
# Thumbnail sizes configuration
THUMB_SIZES = {
'small': settings.thumbnails.small,
'medium': settings.thumbnails.medium,
'large': settings.thumbnails.large
}
def get_thumb_path(photo_id: str, size: str) -> str:
"""Get the path for a thumbnail file"""
thumb_dir = f"/data/thumbs/{photo_id}"
os.makedirs(thumb_dir, exist_ok=True)
return f"{thumb_dir}/{size}.{settings.thumbnails.format}"
def process_standard_image(filepath: str) -> Image.Image:
"""Process standard image formats (JPEG, PNG, etc.)"""
return Image.open(filepath)
def process_raw_image(filepath: str) -> Image.Image:
"""Process RAW image formats"""
if RAWPY_AVAILABLE:
try:
with rawpy.imread(filepath) as raw:
# Use half_size for faster processing
rgb = raw.postprocess(use_camera_wb=True, half_size=True)
# Convert numpy array to PIL Image
return Image.fromarray(rgb, 'RGB')
except Exception as e:
logger.error(f"Error processing RAW file {filepath}: {e}")
# Try to extract embedded JPEG preview
return extract_raw_preview(filepath)
else:
# Use exiftool to extract embedded preview
return extract_raw_preview(filepath)
def extract_raw_preview(filepath: str) -> Optional[Image.Image]:
"""Extract embedded JPEG preview from RAW file"""
try:
# Use exiftool to extract preview
import subprocess
import tempfile
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
cmd = ['exiftool', '-b', '-PreviewImage', filepath]
result = subprocess.run(cmd, capture_output=True)
if result.returncode == 0 and result.stdout:
tmp.write(result.stdout)
tmp.flush()
return Image.open(tmp.name)
except Exception as e:
logger.error(f"Error extracting RAW preview from {filepath}: {e}")
return None
def process_heic_image(filepath: str) -> Image.Image:
"""Process HEIC/HEIF image formats"""
try:
# Use pillow-heif to open the image
img = Image.open(filepath)
# Convert to RGB if needed
if img.mode != 'RGB':
img = img.convert('RGB')
return img
except Exception as e:
logger.error(f"Error processing HEIC file {filepath}: {e}")
raise
def process_video_thumbnail(filepath: str) -> Image.Image:
"""Extract thumbnail from video file"""
try:
# Get video duration
probe = ffmpeg.probe(filepath)
duration = float(probe['streams'][0]['duration'])
# Extract frame at 10% of duration
timestamp = duration * 0.1
# Extract frame using ffmpeg
import tempfile
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
stream = ffmpeg.input(filepath, ss=timestamp)
stream = ffmpeg.output(stream, tmp.name, vframes=1, format='image2', vcodec='mjpeg')
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
return Image.open(tmp.name)
except Exception as e:
logger.error(f"Error extracting video thumbnail from {filepath}: {e}")
# Create a placeholder thumbnail
return create_placeholder_thumbnail('video')
def create_placeholder_thumbnail(media_type: str) -> Image.Image:
"""Create a placeholder thumbnail for failed processing"""
# Create a simple gray placeholder
img = Image.new('RGB', (640, 480), color=(128, 128, 128))
return img
def auto_rotate_image(image: Image.Image) -> Image.Image:
"""Auto-rotate image based on EXIF orientation"""
try:
# Get EXIF data
exif = image._getexif()
if exif:
orientation = exif.get(274) # Orientation tag
rotation_map = {
3: 180,
6: 270, # Note: PIL uses different rotation values than vips
8: 90
}
if orientation in rotation_map:
image = image.rotate(rotation_map[orientation], expand=True)
except:
pass # No orientation data available
return image
def generate_thumbnail(image: Image.Image, size: int, output_path: str):
"""Generate a thumbnail of the specified size"""
# Maintain aspect ratio
image.thumbnail((size, size), Image.Resampling.LANCZOS)
# Save as WebP with specified quality
image.save(
output_path,
'WEBP',
quality=settings.thumbnails.quality,
method=4 # Balance between speed and compression
)
@shared_task(bind=True, name='generate_thumbnails')
def generate_thumbnails(self, photo_id: str):
"""Generate thumbnails for a photo"""
return asyncio.run(_generate_thumbnails_async(photo_id, self))
async def _generate_thumbnails_async(photo_id: str, task):
"""Async implementation of thumbnail generation"""
async with AsyncSessionLocal() as session:
try:
# Get photo from database
result = await session.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
logger.error(f"Photo not found: {photo_id}")
return {'status': 'error', 'message': 'Photo not found'}
# Check if file exists
if not os.path.exists(photo.filepath):
logger.error(f"File not found: {photo.filepath}")
photo.processing_status = 'failed'
photo.processing_error = 'File not found'
await session.commit()
return {'status': 'error', 'message': 'File not found'}
# Update processing status
photo.processing_status = 'processing'
await session.commit()
# Load and process the image based on type
image = None
if photo.media_type == 'photo':
image = process_standard_image(photo.filepath)
elif photo.media_type == 'raw':
image = process_raw_image(photo.filepath)
elif photo.media_type == 'heic':
image = process_heic_image(photo.filepath)
elif photo.media_type == 'video':
image = process_video_thumbnail(photo.filepath)
else:
logger.error(f"Unsupported media type: {photo.media_type}")
image = create_placeholder_thumbnail(photo.media_type)
if not image:
raise Exception("Failed to process image")
# Auto-rotate based on EXIF
image = auto_rotate_image(image)
# Store original dimensions
photo.width = image.width
photo.height = image.height
# Generate thumbnails for each size
for size_name, size_value in THUMB_SIZES.items():
thumb_path = get_thumb_path(photo_id, size_name)
generate_thumbnail(image, size_value, thumb_path)
# Update database with thumbnail path
setattr(photo, f'thumb_{size_name}', thumb_path)
# Update progress
task.update_state(
state='PROGRESS',
meta={'current_size': size_name, 'photo_id': photo_id}
)
# Update processing status
photo.processing_status = 'completed'
photo.processing_error = None
await session.commit()
logger.info(f"Thumbnails generated for photo {photo_id}")
return {'status': 'success', 'photo_id': photo_id}
except Exception as e:
logger.error(f"Error generating thumbnails for {photo_id}: {e}")
# Update error status
if photo:
photo.processing_status = 'failed'
photo.processing_error = str(e)
await session.commit()
return {'status': 'error', 'message': str(e)}
@shared_task(name='regenerate_all_thumbnails')
def regenerate_all_thumbnails():
"""Regenerate thumbnails for all photos"""
return asyncio.run(_regenerate_all_thumbnails_async())
async def _regenerate_all_thumbnails_async():
"""Async implementation of regenerating all thumbnails"""
async with AsyncSessionLocal() as session:
# Get all photos that need thumbnails
result = await session.execute(
select(Photo).where(
Photo.processing_status.in_(['pending', 'failed'])
)
)
photos = result.scalars().all()
logger.info(f"Regenerating thumbnails for {len(photos)} photos")
for photo in photos:
generate_thumbnails.delay(photo.id)
return {'status': 'queued', 'count': len(photos)}

View File

@@ -1,49 +0,0 @@
# Core dependencies
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# Database
sqlalchemy[asyncio]==2.0.25
aiosqlite==0.19.0
alembic==1.13.1
# Redis and Celery
redis==5.0.1
celery==5.3.6
flower==2.0.1
# Image processing
# pyvips==2.2.1 # Optional - having compatibility issues, using Pillow as fallback
# rawpy==0.19.0 # Optional - numpy compatibility issues, using Pillow as fallback
pillow==10.2.0
pillow-heif==0.15.0
imageio==2.33.1
imageio-ffmpeg==0.4.9
# Video processing
ffmpeg-python==0.2.0
# Metadata extraction
pyexiftool==0.5.6
# File watching
watchfiles==0.21.0
# Utilities
pyyaml==6.0.1
pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
httpx==0.26.0
aiofiles==23.2.1
# Security and authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
# Development
pytest==7.4.4
pytest-asyncio==0.23.3
black==23.12.1
ruff==0.1.11

24
docker-compose.gpu.yml Normal file
View File

@@ -0,0 +1,24 @@
# Overlay for hosts with a VA-API-capable GPU passed through (Intel
# QSV, AMD VCN/VCE, any VA-API driver). PhotoPrism's :latest image
# ships VA-API-enabled ffmpeg; this file just wires the device + group
# membership + encoder selection. Layered in by the deploy script on
# hosts where /dev/dri/renderD128 exists.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
services:
photoprism:
devices:
- /dev/dri/renderD128:/dev/dri/renderD128
- /dev/dri/card0:/dev/dri/card0
# Match host GIDs (render=992, video=44 on Debian). PhotoPrism's
# container user (PP_UID:PP_GID, typically 33:10000) is not in
# these groups by default; group_add grants access to the device
# nodes without changing the primary user.
group_add:
- "992"
- "44"
environment:
PHOTOPRISM_FFMPEG_ENCODER: ${PP_FFMPEG_ENCODER:-vaapi}
PHOTOPRISM_FFMPEG_BITRATE: ${PP_FFMPEG_BITRATE:-32}

34
docker-compose.podman.yml Normal file
View File

@@ -0,0 +1,34 @@
# Podman-rootless overlay for the PhotoPrism stack.
#
# Apply alongside the base compose file:
# podman-compose --env-file .env \
# -f docker-compose.yml \
# -f docker-compose.podman.yml \
# up -d
#
# Adds the podman-specific bits that would break a vanilla docker compose run:
# - userns_mode: keep-id maps container UID to the invoking host UID, so
# PhotoPrism (running as PP_UID:PP_GID inside) can actually read the
# bind-mounted originals volume on the host (which is owned by the host
# user, not by uid 1000-in-the-container-namespace).
# - the explicit security_opt entries on the base file work in podman as-is.
services:
# MariaDB writes to a named volume managed by podman; its in-container
# `mysql` user expects to own that volume. keep-id breaks this by mapping
# in-container UID 999 to a podman-subuid that doesn't own the volume,
# so let mariadb use the default userns mapping (root-in-namespace).
mariadb:
# No userns_mode override — use podman defaults.
init: true
# PhotoPrism does need keep-id, so its container UID maps back to the
# host UID that owns the bind-mounted originals/.
photoprism:
userns_mode: keep-id
# Sidecar mutates the originals tree (rename / folder ops / heap
# convert / .duplicates archive) — same keep-id mapping so its writes
# land as the host user, not as a podman-subuid the host doesn't own.
sidecar:
userns_mode: keep-id

View File

@@ -1,90 +1,209 @@
version: '3.8'
# Compose stack for the PhotoPrism-backed photo app: mariadb + photoprism +
# Go sidecar. The SvelteKit web/ frontend runs separately (Vite in dev,
# static build in prod) and proxies /api/v1/* to photoprism and
# /api/sidecar/* to the sidecar.
#
# podman-compose --env-file .env \
# -f docker-compose.yml -f docker-compose.podman.yml up -d
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: mulita-frontend
ports:
- "3000:80"
depends_on:
- backend
networks:
- mulita-network
mariadb:
# Fully-qualified for podman (which refuses short names by default).
# Docker resolves the same digest.
image: docker.io/library/mariadb:11
container_name: pp-mariadb
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: mulita-backend
ports:
- "8001:8000"
volumes:
- ./mulita.yml:/app/config/mulita.yml:ro
- ${PHOTO_DIRS:-./photos}:/photos:rw
- ~/Pictures:/host/Pictures:ro
- thumbs_data:/data/thumbs
- db_data:/data/db
- trash_data:/data/trash
command:
- --innodb-buffer-pool-size=512M
- --transaction-isolation=READ-COMMITTED
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --max-connections=512
- --innodb-rollback-on-timeout=OFF
- --innodb-lock-wait-timeout=120
environment:
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
- REDIS_URL=redis://redis:6379
- CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
depends_on:
- redis
networks:
- mulita-network
restart: unless-stopped
worker:
build:
context: ./backend
dockerfile: Dockerfile
container_name: mulita-worker
command: celery -A app.tasks.celery worker --loglevel=info --concurrency=4
volumes:
- ./mulita.yml:/app/config/mulita.yml:ro
- ${PHOTO_DIRS:-./photos}:/photos:rw
- ~/Pictures:/host/Pictures:ro
- thumbs_data:/data/thumbs
- db_data:/data/db
- trash_data:/data/trash
environment:
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
- REDIS_URL=redis://redis:6379
- CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
- CELERYD_CONCURRENCY=4
depends_on:
- redis
- backend
networks:
- mulita-network
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: mulita-redis
MARIADB_AUTO_UPGRADE: "1"
MARIADB_INITDB_SKIP_TZINFO: "1"
MARIADB_DATABASE: ${PP_DB_NAME:-photoprism}
MARIADB_USER: ${PP_DB_USER:-photoprism}
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env}
MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env}
# Loopback-only host port so the mule-sidecar (running as a host process
# in M4) can reach `mule_sidecar.*` over TCP. Not exposed beyond
# 127.0.0.1; the photoprism container still resolves mariadb by service
# name on the photoprism-network bridge.
ports:
- "6379:6379"
- "127.0.0.1:${PP_DB_PORT:-3306}:3306"
volumes:
- redis_data:/data
networks:
- mulita-network
- pp_mariadb_data:/var/lib/mysql
# The init script creates the mule_sidecar database + user that the Go
# sidecar service will use in M4. Idempotent; no-op on subsequent boots.
# ":Z" is the SELinux private-relabel flag — needed on Fedora/RHEL hosts,
# silently no-op on Debian/Ubuntu and macOS Docker Desktop.
- ./mariadb/init:/docker-entrypoint-initdb.d:ro,Z
healthcheck:
test: ["CMD", "/usr/bin/mariadb-admin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
networks: [photoprism-network]
photoprism:
image: docker.io/photoprism/photoprism:latest
container_name: pp-app
restart: unless-stopped
command: redis-server --appendonly yes
depends_on:
mariadb:
condition: service_healthy
# PhotoPrism's container drops to a non-root user via PHOTOPRISM_UID /
# PHOTOPRISM_GID. Match the host user that owns ${PHOTO_DIRS} so the
# process can read originals (and later write sidecars).
user: "${PP_UID:-1000}:${PP_GID:-1000}"
security_opt:
- seccomp:unconfined
- apparmor:unconfined
ports:
# Loopback only — the SvelteKit web/ app (Vite dev or built bundle)
# is the user-facing surface; PhotoPrism's own UI stays off the
# public interface. Vite proxies /api/v1/* here, and the host-mode
# sidecar reaches PHOTOPRISM_BASE_URL=http://localhost:2342. Admin
# access to PP's UI is via SSH tunnel only.
- "127.0.0.1:${PP_PORT:-2342}:2342"
environment:
PHOTOPRISM_ADMIN_USER: ${PP_ADMIN_USER:-admin}
PHOTOPRISM_ADMIN_PASSWORD: ${PP_ADMIN_PASSWORD:?set PP_ADMIN_PASSWORD in .env}
PHOTOPRISM_AUTH_MODE: ${PP_AUTH_MODE:-password}
PHOTOPRISM_SITE_URL: ${PP_SITE_URL:-http://localhost:2342/}
PHOTOPRISM_ORIGINALS_LIMIT: ${PP_ORIGINALS_LIMIT:-50000}
PHOTOPRISM_HTTP_COMPRESSION: gzip
PHOTOPRISM_LOG_LEVEL: ${PP_LOG_LEVEL:-info}
# Indexer concurrency. Defaults to NumCPU/2 (= 3 on a 6-core LXC),
# but each worker forks TF + ffmpeg + libvips so effective load is
# much higher — a fresh index of 1.2k photos on M0 pushed the LXC
# load to 50+ and starved sibling containers. Pin to a low value
# for shared hosts; raise on dedicated machines.
PHOTOPRISM_WORKERS: ${PP_WORKERS:-2}
# podman-compose doesn't expand nested ${A:-${B:-…}}, so keep this
# one-level. Override both PP_WORKERS and PP_INDEX_WORKERS if you
# want them to differ.
PHOTOPRISM_INDEX_WORKERS: ${PP_INDEX_WORKERS:-2}
# M0 safety: keep originals read-only. Flip to "false" in M2 when the
# right-sidebar enables metadata edits and we want EXIF backwrite.
PHOTOPRISM_READONLY: ${PP_READONLY:-true}
PHOTOPRISM_EXPERIMENTAL: "false"
PHOTOPRISM_DISABLE_CHOWN: "true"
PHOTOPRISM_DISABLE_WEBDAV: ${PP_DISABLE_WEBDAV:-false}
PHOTOPRISM_DISABLE_SETTINGS: "false"
PHOTOPRISM_DISABLE_TLS: "true"
PHOTOPRISM_DEFAULT_TLS: "false"
# AI/vision pipeline back on — per plan we re-introduce TF labels + faces.
PHOTOPRISM_TENSORFLOW_OFF: "false"
PHOTOPRISM_DETECT_NSFW: "true"
PHOTOPRISM_UPLOAD_NSFW: "true"
# Database
PHOTOPRISM_DATABASE_DRIVER: mysql
PHOTOPRISM_DATABASE_SERVER: mariadb:3306
PHOTOPRISM_DATABASE_NAME: ${PP_DB_NAME:-photoprism}
PHOTOPRISM_DATABASE_USER: ${PP_DB_USER:-photoprism}
PHOTOPRISM_DATABASE_PASSWORD: ${PP_DB_PASSWORD}
# Sidecars next to originals — read by the migrator at M5.
PHOTOPRISM_SIDECAR_PATH: ""
PHOTOPRISM_SIDECAR_YAML: "true"
# EXIF backwrite — disabled in M0 (READONLY blocks writes anyway).
# Override in .env: PP_BACKUP_DATABASE=true.
PHOTOPRISM_DISABLE_BACKUPS: "false"
PHOTOPRISM_BACKUP_DATABASE: ${PP_BACKUP_DATABASE:-true}
PHOTOPRISM_DISABLE_EXIFTOOL: "false"
# OIDC — set in .env when the IdP (Authentik) is wired up.
# Empty values keep OIDC dormant; the username/password login still works.
# PhotoPrism's CLI flags are --oidc-uri / --oidc-client / --oidc-secret
# / --oidc-provider, so the env-var names it actually reads are
# PHOTOPRISM_OIDC_URI / _CLIENT / _SECRET / _PROVIDER (NOT _ISSUER_URL
# / _CLIENT_ID / _CLIENT_SECRET / _PROVIDER_NAME — those are silently
# ignored, OIDC stays dormant, and `photoprism show config` reports
# blank oidc-uri / oidc-client). PHOTOPRISM_OIDC_REDIRECT is a bool
# (auto-redirect-from-/library/login), not a URL — PhotoPrism builds
# the callback from PHOTOPRISM_SITE_URL.
PHOTOPRISM_OIDC_PROVIDER: ${OIDC_PROVIDER_NAME:-${OIDC_PROVIDER:-}}
PHOTOPRISM_OIDC_URI: ${OIDC_ISSUER_URL:-${OIDC_URI:-}}
PHOTOPRISM_OIDC_CLIENT: ${OIDC_CLIENT_ID:-${OIDC_CLIENT:-}}
PHOTOPRISM_OIDC_SECRET: ${OIDC_CLIENT_SECRET:-${OIDC_SECRET:-}}
PHOTOPRISM_OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
PHOTOPRISM_OIDC_REGISTER: ${OIDC_REGISTER:-true}
PHOTOPRISM_OIDC_ROLE: ${OIDC_ROLE:-user}
PHOTOPRISM_OIDC_REDIRECT: ${OIDC_REDIRECT:-false}
working_dir: /photoprism
volumes:
# Existing photo library — mounted read-only in M0; flip to :rw in M2
# when the right-sidebar starts saving edits. ",Z" relabels for SELinux
# on Fedora/RHEL; silent no-op elsewhere.
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env}:/photoprism/originals:${PP_ORIGINALS_MODE:-ro},Z"
- "./pp/storage:/photoprism/storage:Z"
- "./pp/import:/photoprism/import:Z"
networks: [photoprism-network]
# mule-sidecar — Go + Gin + GORM service for endpoints PhotoPrism's API
# does not expose (file rename, folder mutations, heap convert, duplicate
# scan, per-photo marks). Same wire contract as the M3 Node prototype;
# the SvelteKit dev server proxies /api/sidecar/* here.
sidecar:
build:
context: ./sidecar
container_name: pp-sidecar
restart: unless-stopped
depends_on:
mariadb:
condition: service_healthy
photoprism:
condition: service_started
# Match PhotoPrism's UID/GID so renames/folder mutations preserve the
# ownership the indexer expects on the bind-mounted originals.
user: "${PP_UID:-1000}:${PP_GID:-1000}"
ports:
# Loopback only — Vite (host) proxies /api/sidecar/* to this port.
# Behind a reverse proxy in production; never published beyond the
# host.
- "127.0.0.1:${SIDECAR_PORT:-8000}:8000"
environment:
ORIGINALS_ROOT: /photoprism/originals
PHOTOPRISM_BASE_URL: http://photoprism:2342
# Bind on all interfaces inside the container so the host-side
# 127.0.0.1:8000 port mapping can reach the listener. The Go
# binary defaults to 127.0.0.1 for the host-mode dev loop.
SIDECAR_LISTEN_ADDR: 0.0.0.0
SIDECAR_PORT: "8000"
SIDECAR_DB_HOST: mariadb
SIDECAR_DB_PORT: "3306"
SIDECAR_DB_USER: sidecar
# Rotate before any non-local deployment. Provisioned by
# mariadb/init/01-sidecar.sql on first boot of the mariadb volume.
SIDECAR_DB_PASSWORD: ${SIDECAR_DB_PASSWORD:-replace-at-m4-bringup}
SIDECAR_DB_NAME: mule_sidecar
# Second DB connection for poking PhotoPrism's own schema (only
# used by the user-basepath reconciler today). Stays inert if
# PP_DB_PASSWORD is empty — the reconciler then silently no-ops.
PP_DB_HOST: mariadb
PP_DB_PORT: "3306"
PP_DB_USER: ${PP_DB_USER:-photoprism}
PP_DB_PASSWORD: ${PP_DB_PASSWORD:-}
PP_DB_NAME: ${PP_DB_NAME:-photoprism}
# Declarative username → originals-relative BasePath mapping.
# Format: comma-separated `user:path` pairs. Sidecar applies it
# to auth_users on boot and every 60s, and `mkdir -p`s each
# target subdirectory so PhotoPrism's ACL filter has somewhere to
# point. Leave empty to disable.
# USER_BASEPATHS="test:test, alice:family/alice"
USER_BASEPATHS: ${USER_BASEPATHS:-}
volumes:
# Sidecar mutates originals (rename, folder mutations, heap
# convert) — always rw regardless of PhotoPrism's mount mode.
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env}:/photoprism/originals:rw,Z"
networks: [photoprism-network]
networks:
mulita-network:
photoprism-network:
driver: bridge
volumes:
thumbs_data:
db_data:
trash_data:
redis_data:
pp_mariadb_data:

View File

@@ -1,31 +0,0 @@
# Build stage
FROM node:18-alpine as build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mulita - Photo Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,43 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# API proxy
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for real-time updates
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Thumbnail serving with X-Accel-Redirect
location /internal_thumbs/ {
internal;
alias /data/thumbs/;
}
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +0,0 @@
{
"name": "mulita-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.17.0",
"@tanstack/react-virtual": "^3.0.1",
"axios": "^1.6.5",
"clsx": "^2.1.0",
"date-fns": "^3.2.0",
"framer-motion": "^10.18.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.303.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.3",
"react-intersection-observer": "^9.5.3",
"react-leaflet": "^4.2.1",
"tailwind-merge": "^2.2.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.96.2",
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,75 +0,0 @@
import { useState } from 'react'
import { Timeline } from './components/timeline/Timeline'
import { LeftSidebar } from './components/layout/LeftSidebar'
import { RightSidebar } from './components/layout/RightSidebar'
import { TopBar } from './components/layout/TopBar'
import { ScanProgress } from './components/ScanProgress'
import { ToastContainer } from './components/ToastContainer'
import { KeyboardShortcuts } from './components/KeyboardShortcuts'
import { KeyboardHints } from './components/KeyboardHints'
import { usePhotoStore } from './store/photoStore'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
function App() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
// Set up global keyboard shortcuts
useKeyboardShortcuts({
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen),
})
// Show right sidebar when photos are selected
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
setRightSidebarOpen(true)
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
setRightSidebarOpen(false)
}
return (
<div className="flex flex-col h-screen bg-bg text-text">
<TopBar />
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */}
<div
className={`transition-all duration-200 ${
leftSidebarOpen ? 'w-64' : 'w-0'
} overflow-hidden border-r border-border bg-surface`}
>
<LeftSidebar />
</div>
{/* Main Content - Timeline */}
<div className="flex-1 overflow-auto">
<Timeline />
</div>
{/* Right Sidebar */}
<div
className={`transition-all duration-200 ${
rightSidebarOpen ? 'w-80' : 'w-0'
} overflow-hidden border-l border-border bg-surface`}
>
<RightSidebar />
</div>
</div>
{/* Contextual Keyboard Hints */}
<KeyboardHints />
{/* Keyboard Shortcuts Legend */}
<KeyboardShortcuts />
{/* Scan Progress Indicator */}
<ScanProgress />
{/* Toast Notifications */}
<ToastContainer />
</div>
)
}
export default App

View File

@@ -1,45 +0,0 @@
import { usePhotoStore } from '../store/photoStore'
export function KeyboardHints() {
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
const hints = selectedCount > 0 ? [
{ key: '1-5', action: 'Rate' },
{ key: 'P', action: 'Pick' },
{ key: 'X', action: 'Reject' },
{ key: 'Delete', action: 'Trash' },
{ key: 'Esc', action: 'Deselect' },
] : [
{ key: '↑↓←→', action: 'Navigate' },
{ key: 'Click', action: 'Select' },
{ key: 'Shift+Click', action: 'Range' },
{ key: 'Ctrl+A', action: 'Select All' },
{ key: 'Space', action: 'Preview' },
]
return (
<div className="fixed top-14 left-1/2 z-20 -translate-x-1/2">
<div className="flex items-center gap-3 rounded-full border border-border bg-surface/90 px-4 py-2 shadow-lg backdrop-blur-sm">
{hints.map((hint, i) => (
<div key={i} className="flex items-center gap-1.5">
<kbd className="rounded bg-surface-offset px-2 py-0.5 text-xs font-medium text-text">
{hint.key}
</kbd>
<span className="text-xs text-text-muted">{hint.action}</span>
{i < hints.length - 1 && (
<span className="ml-2 text-text-faint"></span>
)}
</div>
))}
{selectedCount > 0 && (
<>
<span className="ml-2 text-text-faint"></span>
<span className="text-xs font-medium text-primary">
{selectedCount} selected
</span>
</>
)}
</div>
</div>
)
}

View File

@@ -1,143 +0,0 @@
import { useState } from 'react'
import { Keyboard, ChevronRight, ChevronDown, X } from 'lucide-react'
import clsx from 'clsx'
interface Shortcut {
keys: string[]
description: string
category: 'navigation' | 'selection' | 'actions' | 'view'
}
const shortcuts: Shortcut[] = [
// Navigation
{ keys: ['↑', '↓', '←', '→'], description: 'Navigate photos', category: 'navigation' },
{ keys: ['Space'], description: 'Quick preview', category: 'navigation' },
{ keys: ['Enter'], description: 'Open in loupe view', category: 'navigation' },
// Selection
{ keys: ['Click'], description: 'Select photo', category: 'selection' },
{ keys: ['Shift', 'Click'], description: 'Select range', category: 'selection' },
{ keys: ['Ctrl/Cmd', 'Click'], description: 'Add to selection', category: 'selection' },
{ keys: ['Ctrl/Cmd', 'A'], description: 'Select all', category: 'selection' },
{ keys: ['Escape'], description: 'Clear selection', category: 'selection' },
// Actions
{ keys: ['1-5'], description: 'Set rating', category: 'actions' },
{ keys: ['0'], description: 'Remove rating', category: 'actions' },
{ keys: ['P'], description: 'Pick photo', category: 'actions' },
{ keys: ['X'], description: 'Reject photo', category: 'actions' },
{ keys: ['U'], description: 'Unflag photo', category: 'actions' },
{ keys: ['Delete'], description: 'Move to trash', category: 'actions' },
// View
{ keys: ['Tab'], description: 'Toggle left sidebar', category: 'view' },
{ keys: ['I'], description: 'Toggle info panel', category: 'view' },
{ keys: ['G'], description: 'Grid view', category: 'view' },
{ keys: ['E'], description: 'Loupe view', category: 'view' },
{ keys: ['F'], description: 'Fullscreen', category: 'view' },
]
export function KeyboardShortcuts() {
const [isExpanded, setIsExpanded] = useState(true)
const [isMinimized, setIsMinimized] = useState(false)
const categories = {
navigation: { label: 'Navigation', color: 'text-primary' },
selection: { label: 'Selection', color: 'text-pick' },
actions: { label: 'Actions', color: 'text-star' },
view: { label: 'View', color: 'text-text' },
}
if (isMinimized) {
return (
<div className="fixed bottom-4 left-4 z-30">
<button
onClick={() => setIsMinimized(false)}
className="flex items-center gap-2 rounded-lg border border-border bg-surface/90 px-3 py-2 text-sm backdrop-blur-sm hover:bg-surface"
title="Show keyboard shortcuts"
>
<Keyboard className="h-4 w-4 text-primary" />
<span className="text-text-muted">Shortcuts</span>
</button>
</div>
)
}
return (
<div className="fixed bottom-4 left-4 z-30 w-80 overflow-hidden rounded-lg border border-border bg-surface/95 shadow-xl backdrop-blur-sm">
{/* Header */}
<div className="flex items-center justify-between bg-surface-2 px-3 py-2">
<div className="flex items-center gap-2">
<Keyboard className="h-4 w-4 text-primary" />
<span className="text-sm font-medium text-text">Keyboard Shortcuts</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded p-1 text-text-muted hover:bg-surface-offset hover:text-text"
title={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
<button
onClick={() => setIsMinimized(true)}
className="rounded p-1 text-text-muted hover:bg-surface-offset hover:text-text"
title="Minimize"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
{/* Content */}
{isExpanded && (
<div className="max-h-96 overflow-y-auto p-2">
{Object.entries(categories).map(([category, { label, color }]) => (
<div key={category} className="mb-3">
<h3 className={clsx('mb-1.5 text-xs font-semibold uppercase', color)}>
{label}
</h3>
<div className="space-y-1">
{shortcuts
.filter(s => s.category === category)
.map((shortcut, i) => (
<div
key={i}
className="flex items-center justify-between rounded px-2 py-1 hover:bg-surface-2"
>
<span className="text-xs text-text-muted">
{shortcut.description}
</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, j) => (
<span key={j} className="flex items-center">
<kbd className="rounded bg-surface-offset px-1.5 py-0.5 text-[10px] font-medium text-text">
{key}
</kbd>
{j < shortcut.keys.length - 1 && (
<span className="mx-0.5 text-[10px] text-text-muted">+</span>
)}
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Footer Hint */}
{!isExpanded && (
<div className="px-3 pb-2 pt-1">
<p className="text-xs text-text-muted">Click to expand shortcuts list</p>
</div>
)}
</div>
)
}

View File

@@ -1,166 +0,0 @@
import { useEffect, useState } from 'react'
import { FolderOpen, Loader2, Check, AlertCircle, X } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { library } from '../services/api'
import clsx from 'clsx'
interface ScanStatus {
is_scanning: boolean
current_folder?: string
processed_files: number
total_files: number
errors: string[]
}
export function ScanProgress() {
const [isVisible, setIsVisible] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
// Poll scan status every 2 seconds when scanning
const { data: scanStatus } = useQuery<ScanStatus>({
queryKey: ['scan-status'],
queryFn: async () => {
const response = await library.scanStatus()
return response
},
refetchInterval: (query) => {
// Poll every 2 seconds if scanning, otherwise every 10 seconds
return query.state.data?.is_scanning ? 2000 : 10000
},
enabled: true,
})
useEffect(() => {
if (scanStatus?.is_scanning) {
setIsVisible(true)
setIsMinimized(false)
} else if (isVisible && !scanStatus?.is_scanning && (scanStatus?.processed_files ?? 0) > 0) {
// Keep showing for 3 seconds after scan completes
setTimeout(() => {
if (!scanStatus?.is_scanning) {
setIsVisible(false)
}
}, 3000)
}
}, [scanStatus?.is_scanning, scanStatus?.processed_files, isVisible])
if (!isVisible || !scanStatus) return null
const progress = scanStatus.total_files > 0
? (scanStatus.processed_files / scanStatus.total_files) * 100
: 0
const isComplete = !scanStatus.is_scanning && scanStatus.processed_files > 0
const hasErrors = scanStatus.errors && scanStatus.errors.length > 0
return (
<div
className={clsx(
'fixed bottom-4 right-4 z-40 overflow-hidden rounded-lg border border-border bg-surface shadow-xl transition-all duration-300',
isMinimized ? 'w-12' : 'w-80'
)}
>
{/* Header */}
<div
className="flex cursor-pointer items-center justify-between bg-surface-2 px-3 py-2"
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex items-center gap-2">
{scanStatus.is_scanning ? (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
) : isComplete && !hasErrors ? (
<Check className="h-4 w-4 text-pick" />
) : hasErrors ? (
<AlertCircle className="h-4 w-4 text-reject" />
) : (
<FolderOpen className="h-4 w-4 text-text-muted" />
)}
{!isMinimized && (
<span className="text-sm font-medium text-text">
{scanStatus.is_scanning
? 'Scanning Folders'
: isComplete
? 'Scan Complete'
: 'Scan Status'}
</span>
)}
</div>
{!isMinimized && (
<button
onClick={(e) => {
e.stopPropagation()
setIsVisible(false)
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Content */}
{!isMinimized && (
<div className="p-3">
{/* Current folder */}
{scanStatus.current_folder && (
<div className="mb-2 text-xs text-text-muted">
<span className="font-mono">{scanStatus.current_folder}</span>
</div>
)}
{/* Progress bar */}
<div className="mb-2">
<div className="h-1.5 overflow-hidden rounded-full bg-surface-offset">
<div
className={clsx(
'h-full transition-all duration-300',
scanStatus.is_scanning
? 'bg-primary'
: hasErrors
? 'bg-reject'
: 'bg-pick'
)}
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Stats */}
<div className="flex items-center justify-between text-xs">
<span className="text-text-muted">
{scanStatus.processed_files} / {scanStatus.total_files || '?'} files
</span>
<span className={clsx(
'font-medium',
scanStatus.is_scanning ? 'text-primary' : hasErrors ? 'text-reject' : 'text-pick'
)}>
{scanStatus.is_scanning
? `${Math.round(progress)}%`
: isComplete
? 'Done'
: 'Idle'}
</span>
</div>
{/* Errors */}
{hasErrors && (
<div className="mt-2 max-h-20 overflow-y-auto rounded bg-reject/10 p-2">
<div className="text-xs text-reject">
{scanStatus.errors.slice(0, 3).map((error, i) => (
<div key={i} className="truncate">
{error}
</div>
))}
{scanStatus.errors.length > 3 && (
<div className="mt-1 text-text-muted">
+{scanStatus.errors.length - 3} more errors
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -1,95 +0,0 @@
import { useEffect, useState } from 'react'
import { CheckCircle, XCircle, Info, AlertCircle, X } from 'lucide-react'
import clsx from 'clsx'
export interface Toast {
id: string
type: 'success' | 'error' | 'info' | 'warning'
title: string
message?: string
duration?: number
}
// Global toast state (in production, use Zustand or Context)
let toastListeners: ((toasts: Toast[]) => void)[] = []
let toastList: Toast[] = []
export const toast = {
success: (title: string, message?: string) => addToast('success', title, message),
error: (title: string, message?: string) => addToast('error', title, message),
info: (title: string, message?: string) => addToast('info', title, message),
warning: (title: string, message?: string) => addToast('warning', title, message),
}
function addToast(type: Toast['type'], title: string, message?: string, duration = 5000) {
const id = Date.now().toString()
const newToast: Toast = { id, type, title, message, duration }
toastList = [...toastList, newToast]
toastListeners.forEach(listener => listener(toastList))
// Auto-remove after duration
setTimeout(() => {
removeToast(id)
}, duration)
}
function removeToast(id: string) {
toastList = toastList.filter(t => t.id !== id)
toastListeners.forEach(listener => listener(toastList))
}
export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([])
useEffect(() => {
const listener = (newToasts: Toast[]) => setToasts(newToasts)
toastListeners.push(listener)
return () => {
toastListeners = toastListeners.filter(l => l !== listener)
}
}, [])
const icons = {
success: <CheckCircle className="h-5 w-5 text-pick" />,
error: <XCircle className="h-5 w-5 text-reject" />,
info: <Info className="h-5 w-5 text-primary" />,
warning: <AlertCircle className="h-5 w-5 text-star" />,
}
const colors = {
success: 'border-pick bg-pick/10',
error: 'border-reject bg-reject/10',
info: 'border-primary bg-primary/10',
warning: 'border-star bg-star/10',
}
return (
<div className="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={clsx(
'pointer-events-auto flex items-start gap-3 rounded-lg border p-3 shadow-lg backdrop-blur-sm transition-all duration-300',
'animate-slide-up',
colors[toast.type]
)}
style={{ minWidth: '300px', maxWidth: '400px' }}
>
{icons[toast.type]}
<div className="flex-1">
<div className="font-medium text-text">{toast.title}</div>
{toast.message && (
<div className="mt-0.5 text-sm text-text-muted">{toast.message}</div>
)}
</div>
<button
onClick={() => removeToast(toast.id)}
className="pointer-events-auto rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)
}

View File

@@ -1,154 +0,0 @@
import { useState } from 'react'
import { X, FolderPlus, AlertCircle } from 'lucide-react'
import clsx from 'clsx'
interface AddSourceFolderDialogProps {
isOpen: boolean
onClose: () => void
onAdd: (path: string, recursive: boolean) => Promise<void>
}
export function AddSourceFolderDialog({ isOpen, onClose, onAdd }: AddSourceFolderDialogProps) {
const [folderPath, setFolderPath] = useState('')
const [recursive, setRecursive] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!folderPath.trim()) {
setError('Please enter a folder path')
return
}
setIsLoading(true)
setError(null)
try {
await onAdd(folderPath.trim(), recursive)
setFolderPath('')
setRecursive(true)
onClose()
} catch (err: any) {
setError(err.message || 'Failed to add source folder')
} finally {
setIsLoading(false)
}
}
const handleClose = () => {
if (!isLoading) {
setFolderPath('')
setError(null)
onClose()
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleClose}
/>
{/* Dialog */}
<div className="relative z-10 w-full max-w-md rounded-lg bg-surface border border-border p-6 shadow-xl">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<FolderPlus className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold text-text">Add Source Folder</h2>
</div>
<button
onClick={handleClose}
disabled={isLoading}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Path Input */}
<div>
<label htmlFor="folderPath" className="mb-1 block text-sm text-text-muted">
Folder Path
</label>
<input
id="folderPath"
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/host/Pictures/your-folder"
disabled={isLoading}
className={clsx(
'w-full rounded border bg-bg px-3 py-2 text-sm text-text placeholder-text-faint',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
'disabled:opacity-50',
error ? 'border-reject' : 'border-border'
)}
/>
<div className="mt-1 space-y-1">
<p className="text-xs text-text-muted">
Use container paths. Your Pictures folder is available at:
</p>
<code className="block text-xs bg-surface-2 px-2 py-1 rounded text-primary">
/host/Pictures/
</code>
<p className="text-xs text-text-faint">
Example: /host/Pictures/MulitaTest
</p>
</div>
</div>
{/* Recursive Checkbox */}
<div className="flex items-center gap-2">
<input
id="recursive"
type="checkbox"
checked={recursive}
onChange={(e) => setRecursive(e.target.checked)}
disabled={isLoading}
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
/>
<label htmlFor="recursive" className="text-sm text-text">
Include subfolders
</label>
</div>
{/* Error Message */}
{error && (
<div className="flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleClose}
disabled={isLoading}
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || !folderPath.trim()}
className="rounded bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? 'Adding...' : 'Add Folder'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,262 +0,0 @@
import { useState } from 'react'
import {
ChevronRight,
ChevronDown,
Folder,
Image,
Calendar,
Star,
Flag,
Trash2,
Plus,
MoreHorizontal,
HardDrive,
RefreshCw
} from 'lucide-react'
import clsx from 'clsx'
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
import { sourceFolders, library } from '../../services/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
interface TreeItem {
id: string
label: string
icon?: React.ReactNode
count?: number
children?: TreeItem[]
type?: 'folder' | 'heap' | 'special'
}
export function LeftSidebar() {
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
const [isScanning, setIsScanning] = useState(false)
const queryClient = useQueryClient()
// Fetch folders from API
const { data: foldersData, refetch: refetchFolders } = useQuery({
queryKey: ['folders'],
queryFn: sourceFolders.list,
})
// Mutation for adding folders
const addFolderMutation = useMutation({
mutationFn: async ({ path, recursive }: { path: string; recursive: boolean }) => {
// Add the folder
const folder = await sourceFolders.add(path, recursive)
// Trigger scan for the new folder
await sourceFolders.scan(folder.id)
return folder
},
onSuccess: (folder) => {
toast.success('Folder Added', `Scanning ${folder.name || folder.path}...`)
// Refetch folders list
refetchFolders()
// Refetch photos to show new ones
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (error: any) => {
toast.error('Failed to Add Folder', error.message || 'An error occurred')
},
})
// Mutation for scanning all folders
const scanLibraryMutation = useMutation({
mutationFn: library.scan,
onMutate: () => {
setIsScanning(true)
toast.info('Scan Started', 'Scanning all folders for new photos...')
},
onSuccess: () => {
toast.success('Scan Complete', 'All folders have been scanned')
},
onError: (error: any) => {
toast.error('Scan Failed', error.message || 'Failed to scan folders')
},
onSettled: () => {
setIsScanning(false)
// Refetch photos after scan
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
})
const handleAddFolder = async (path: string, recursive: boolean) => {
await addFolderMutation.mutateAsync({ path, recursive })
}
const handleScanAll = () => {
scanLibraryMutation.mutate()
}
const toggleExpanded = (id: string) => {
const newExpanded = new Set(expandedItems)
if (newExpanded.has(id)) {
newExpanded.delete(id)
} else {
newExpanded.add(id)
}
setExpandedItems(newExpanded)
}
const libraryTree: TreeItem[] = [
{
id: 'library',
label: 'Library',
icon: <HardDrive className="h-4 w-4" />,
children: [
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
{ id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
{ id: 'trash', label: 'Trash', icon: <Trash2 className="h-4 w-4" />, count: 0 },
],
},
{
id: 'folders',
label: 'Folders',
icon: <Folder className="h-4 w-4" />,
children: foldersData?.folders?.map((folder: any) => ({
id: `folder-${folder.id}`,
label: folder.name || folder.path.split('/').pop() || folder.path,
icon: <Folder className="h-4 w-4" />,
count: folder.photo_count,
type: 'folder',
})) || [],
},
{
id: 'heaps',
label: 'Heaps',
icon: <Folder className="h-4 w-4" />,
children: [], // Will be populated from API
},
]
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
const hasChildren = item.children && item.children.length > 0
const isExpanded = expandedItems.has(item.id)
const isSelected = selectedItem === item.id
return (
<div key={item.id}>
<div
className={clsx(
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm',
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
depth > 0 && 'text-[13px]'
)}
style={{ paddingLeft: `${8 + depth * 16}px` }}
onClick={() => {
setSelectedItem(item.id)
if (hasChildren) {
toggleExpanded(item.id)
}
}}
>
{/* Expand/Collapse Icon */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation()
toggleExpanded(item.id)
}}
className="rounded p-0.5 hover:bg-surface-offset"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
) : (
<div className="w-4" />
)}
{/* Item Icon */}
{item.icon && (
<span className={clsx('flex-shrink-0', isSelected ? 'text-primary' : 'text-text-muted')}>
{item.icon}
</span>
)}
{/* Label */}
<span className="flex-1 truncate">{item.label}</span>
{/* Count Badge */}
{item.count !== undefined && item.count > 0 && (
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
{item.count}
</span>
)}
{/* Actions (shown on hover) */}
{(item.id === 'folders' || item.id === 'heaps') && (
<button
onClick={(e) => {
e.stopPropagation()
// Handle add folder/heap
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
>
<Plus className="h-3 w-3" />
</button>
)}
</div>
{/* Render Children */}
{hasChildren && isExpanded && (
<div>
{item.children!.map((child) => renderTreeItem(child, depth + 1))}
</div>
)}
</div>
)
}
return (
<div className="flex h-full flex-col bg-surface">
{/* Sidebar Header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<h2 className="text-sm font-semibold text-text">Library</h2>
<button className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text">
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
{/* Tree View */}
<div className="flex-1 overflow-y-auto py-2">
{libraryTree.map((item) => renderTreeItem(item))}
</div>
{/* Bottom Actions */}
<div className="border-t border-border p-3 space-y-2">
<button
onClick={() => setShowAddFolderDialog(true)}
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset"
>
<Plus className="h-4 w-4" />
Add Source Folder
</button>
{foldersData?.folders?.length > 0 && (
<button
onClick={handleScanAll}
disabled={isScanning}
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
>
<RefreshCw className={clsx("h-4 w-4", isScanning && "animate-spin")} />
{isScanning ? 'Scanning...' : 'Scan All Folders'}
</button>
)}
</div>
{/* Add Source Folder Dialog */}
<AddSourceFolderDialog
isOpen={showAddFolderDialog}
onClose={() => setShowAddFolderDialog(false)}
onAdd={handleAddFolder}
/>
</div>
)
}

View File

@@ -1,302 +0,0 @@
import { useState } from 'react'
import {
X,
Star,
MapPin,
Camera,
Aperture,
Info,
ChevronDown,
ChevronRight,
Check,
Plus
} from 'lucide-react'
import clsx from 'clsx'
import { usePhotoStore } from '../../store/photoStore'
import { format } from 'date-fns'
export function RightSidebar() {
const { selectedPhotos, clearSelection } = usePhotoStore()
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['basic', 'camera', 'location', 'tags'])
)
const [rating, setRating] = useState(0)
const [flagStatus, setFlagStatus] = useState<'none' | 'pick' | 'reject'>('none')
const toggleSection = (section: string) => {
const newExpanded = new Set(expandedSections)
if (newExpanded.has(section)) {
newExpanded.delete(section)
} else {
newExpanded.add(section)
}
setExpandedSections(newExpanded)
}
// Mock photo data - in real app, fetch based on selectedPhotos
const mockPhoto = selectedPhotos.length > 0 ? {
filename: 'IMG_1234.jpg',
size: '3.2 MB',
dimensions: '4032 × 3024',
dateTaken: new Date('2024-01-15T14:30:00'),
camera: 'Canon EOS R5',
lens: 'RF 24-70mm F2.8L IS USM',
iso: 400,
aperture: 'f/2.8',
shutterSpeed: '1/250',
focalLength: '50mm',
location: 'San Francisco, CA',
tags: ['landscape', 'sunset', 'golden hour'],
} : null
if (selectedPhotos.length === 0) {
return (
<div className="flex h-full items-center justify-center p-4 text-center">
<div className="text-text-muted">
<Info className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm">Select photos to view details</p>
</div>
</div>
)
}
const multipleSelected = selectedPhotos.length > 1
return (
<div className="flex h-full flex-col bg-surface">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-text">
{multipleSelected
? `${selectedPhotos.length} Photos Selected`
: 'Photo Details'}
</h2>
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Quick Actions */}
<div className="border-b border-border p-4">
{/* Rating Stars */}
<div className="mb-3">
<label className="mb-1 block text-xs text-text-muted">Rating</label>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
onClick={() => setRating(rating === value ? 0 : value)}
className="p-0.5"
>
<Star
className={clsx(
'h-5 w-5 transition-colors',
value <= rating
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button>
))}
</div>
</div>
{/* Flag Status */}
<div>
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-2">
<button
onClick={() => setFlagStatus(flagStatus === 'pick' ? 'none' : 'pick')}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
flagStatus === 'pick'
? 'bg-pick/20 text-pick'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
>
<Check className="h-3 w-3" />
Pick
</button>
<button
onClick={() => setFlagStatus(flagStatus === 'reject' ? 'none' : 'reject')}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
flagStatus === 'reject'
? 'bg-reject/20 text-reject'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
>
<X className="h-3 w-3" />
Reject
</button>
</div>
</div>
</div>
{/* Metadata Sections */}
<div className="flex-1 overflow-y-auto">
{mockPhoto && (
<>
{/* Basic Info */}
<div className="border-b border-border">
<button
onClick={() => toggleSection('basic')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
>
<span className="font-medium text-text">Basic Info</span>
{expandedSections.has('basic') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
</button>
{expandedSections.has('basic') && (
<div className="px-4 pb-3 text-xs">
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-text-muted">Filename:</span>
<p className="text-text">{mockPhoto.filename}</p>
</div>
<div>
<span className="text-text-muted">Size:</span>
<p className="text-text">{mockPhoto.size}</p>
</div>
<div>
<span className="text-text-muted">Dimensions:</span>
<p className="text-text">{mockPhoto.dimensions}</p>
</div>
<div>
<span className="text-text-muted">Date Taken:</span>
<p className="text-text">
{format(mockPhoto.dateTaken, 'MMM d, yyyy')}
</p>
</div>
</div>
</div>
)}
</div>
{/* Camera Info */}
<div className="border-b border-border">
<button
onClick={() => toggleSection('camera')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
>
<span className="font-medium text-text">Camera</span>
{expandedSections.has('camera') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
</button>
{expandedSections.has('camera') && (
<div className="px-4 pb-3 text-xs">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Camera className="h-3 w-3 text-text-muted" />
<span className="text-text">{mockPhoto.camera}</span>
</div>
<div className="flex items-center gap-2">
<Aperture className="h-3 w-3 text-text-muted" />
<span className="text-text">{mockPhoto.lens}</span>
</div>
<div className="grid grid-cols-2 gap-2 mt-2">
<div>
<span className="text-text-muted">ISO:</span>
<span className="ml-1 text-text">{mockPhoto.iso}</span>
</div>
<div>
<span className="text-text-muted">Aperture:</span>
<span className="ml-1 text-text">{mockPhoto.aperture}</span>
</div>
<div>
<span className="text-text-muted">Shutter:</span>
<span className="ml-1 text-text">{mockPhoto.shutterSpeed}</span>
</div>
<div>
<span className="text-text-muted">Focal:</span>
<span className="ml-1 text-text">{mockPhoto.focalLength}</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* Location */}
<div className="border-b border-border">
<button
onClick={() => toggleSection('location')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
>
<span className="font-medium text-text">Location</span>
{expandedSections.has('location') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
</button>
{expandedSections.has('location') && (
<div className="px-4 pb-3">
<div className="flex items-center gap-2 text-xs">
<MapPin className="h-3 w-3 text-text-muted" />
<span className="text-text">{mockPhoto.location}</span>
</div>
</div>
)}
</div>
{/* Tags */}
<div className="border-b border-border">
<button
onClick={() => toggleSection('tags')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
>
<span className="font-medium text-text">Tags</span>
{expandedSections.has('tags') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
</button>
{expandedSections.has('tags') && (
<div className="px-4 pb-3">
<div className="flex flex-wrap gap-1">
{mockPhoto.tags.map((tag) => (
<span
key={tag}
className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
>
{tag}
</span>
))}
<button className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text-muted hover:bg-surface-offset hover:text-text">
<Plus className="h-3 w-3" />
</button>
</div>
</div>
)}
</div>
</>
)}
</div>
{/* Footer Actions */}
{multipleSelected && (
<div className="border-t border-border p-3">
<div className="space-y-2">
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
Add to Heap
</button>
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
Export Selected
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,159 +0,0 @@
import { useState } from 'react'
import {
Search,
Grid,
List,
SlidersHorizontal,
FolderOpen,
Upload,
Settings,
Menu,
Trash2
} from 'lucide-react'
import clsx from 'clsx'
import { usePhotoStore } from '../../store/photoStore'
import { photos } from '../../services/api'
import { toast } from '../ToastContainer'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import muliLogo from '../../assets/muli-logo.png'
export function TopBar() {
const [searchQuery, setSearchQuery] = useState('')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
const clearSelection = usePhotoStore((state) => state.clearSelection)
const selectedCount = selectedPhotos.length
const queryClient = useQueryClient()
// Mutation for moving photos to trash
const trashPhotosMutation = useMutation({
mutationFn: async () => {
await photos.bulkUpdate(selectedPhotos, { trash: true })
},
onSuccess: () => {
toast.success('Moved to Trash', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} moved to trash`)
clearSelection()
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (error: any) => {
toast.error('Failed to Move to Trash', error.message || 'An error occurred')
},
})
return (
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
{/* Left Section - Menu and App Name */}
<div className="flex items-center gap-3">
<button
className="group relative rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Toggle sidebar (Tab)"
>
<Menu className="h-5 w-5" />
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
Tab
</kbd>
</button>
<div className="flex items-center gap-2">
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
<h1 className="text-lg font-semibold text-text">Mulita</h1>
</div>
{selectedCount > 0 && (
<>
<span className="rounded bg-primary/20 px-2 py-0.5 text-sm text-primary">
{selectedCount} selected
</span>
<button
onClick={() => trashPhotosMutation.mutate()}
disabled={trashPhotosMutation.isPending}
className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50"
title="Move to trash"
>
<Trash2 className="h-3.5 w-3.5" />
Trash
</button>
</>
)}
</div>
{/* Center Section - Search */}
<div className="flex max-w-xl flex-1 items-center px-8">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search photos..."
className="w-full rounded-md border border-border bg-bg py-1.5 pl-9 pr-3 text-sm text-text placeholder-text-muted focus:border-primary focus:outline-none"
/>
</div>
</div>
{/* Right Section - View Controls and Actions */}
<div className="flex items-center gap-2">
{/* View Mode Toggle */}
<div className="flex rounded-md border border-border">
<button
className={clsx(
'rounded-l-md px-2 py-1',
viewMode === 'grid'
? 'bg-primary text-white'
: 'bg-surface text-text-muted hover:bg-surface-2'
)}
onClick={() => setViewMode('grid')}
title="Grid view"
>
<Grid className="h-4 w-4" />
</button>
<button
className={clsx(
'rounded-r-md px-2 py-1',
viewMode === 'list'
? 'bg-primary text-white'
: 'bg-surface text-text-muted hover:bg-surface-2'
)}
onClick={() => setViewMode('list')}
title="List view"
>
<List className="h-4 w-4" />
</button>
</div>
{/* Filter Button */}
<button
className="group relative rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Filter photos (Ctrl+F)"
>
<SlidersHorizontal className="h-4 w-4" />
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
Ctrl+F
</kbd>
</button>
<div className="mx-1 h-6 w-px bg-border" />
{/* Action Buttons */}
<button
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Add folder"
>
<FolderOpen className="h-4 w-4" />
</button>
<button
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Import photos"
>
<Upload className="h-4 w-4" />
</button>
<button
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Settings"
>
<Settings className="h-4 w-4" />
</button>
</div>
</header>
)
}

View File

@@ -1,126 +0,0 @@
import { useState, useEffect } from 'react'
import { Star, Check, X } from 'lucide-react'
import clsx from 'clsx'
interface Photo {
id: string
filepath: string
filename: string
width: number | null
height: number | null
taken_at: string | null
rating: number
is_picked: boolean
is_rejected: boolean
file_hash: string
media_type: string
}
interface PhotoThumbnailProps {
photo: Photo
size: number
isSelected: boolean
onClick: (e: React.MouseEvent) => void
}
export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbnailProps) {
const [imageError, setImageError] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false)
// Generate thumbnail URL - assuming backend serves thumbnails at /api/photos/{id}/thumbnail
const thumbnailUrl = `http://localhost:8001/api/v1/photos/${photo.id}/thumb/medium`
// Calculate aspect ratio for proper sizing (default to 1:1 if dimensions unknown)
const aspectRatio = (photo.height && photo.width) ? photo.height / photo.width : 1
const displayHeight = size * Math.min(aspectRatio, 1.5) // Cap height at 1.5x width
const handleImageLoad = () => {
setImageLoaded(true)
}
const handleImageError = () => {
setImageError(true)
}
// Reset state when photo changes
useEffect(() => {
setImageError(false)
setImageLoaded(false)
}, [photo.id])
return (
<div
className={clsx(
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
'hover:ring-2 hover:ring-primary/50',
isSelected && 'ring-2 ring-primary shadow-lg',
!imageLoaded && 'bg-surface animate-pulse'
)}
style={{
width: size,
height: displayHeight,
}}
onClick={onClick}
title="Click to select • Shift+Click for range • Ctrl+Click to add"
>
{/* Thumbnail Image */}
{!imageError ? (
<img
src={thumbnailUrl}
alt={photo.filename}
className={clsx(
'h-full w-full object-cover transition-opacity duration-200',
imageLoaded ? 'opacity-100' : 'opacity-0'
)}
onLoad={handleImageLoad}
onError={handleImageError}
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-surface text-text-muted">
<div className="text-center text-xs">
<div>Unable to load</div>
<div className="mt-1 font-mono text-[10px]">{photo.filename}</div>
</div>
</div>
)}
{/* Selection Indicator */}
{isSelected && (
<div className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white">
<Check className="h-4 w-4" />
</div>
)}
{/* Rating Stars */}
{photo.rating > 0 && (
<div className="absolute bottom-1 left-1 flex gap-0.5">
{Array.from({ length: photo.rating }).map((_, i) => (
<Star
key={i}
className="h-3 w-3 fill-star text-star"
/>
))}
</div>
)}
{/* Flag Indicators */}
<div className="absolute bottom-1 right-1">
{photo.is_picked && (
<Check className="h-4 w-4 text-pick" />
)}
{photo.is_rejected && (
<X className="h-4 w-4 text-reject" />
)}
</div>
{/* File Type Badge for RAW/Video */}
{(photo.filepath.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && (
<div className="absolute right-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[10px] font-medium text-white">
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
</div>
)}
</div>
)
}

View File

@@ -1,260 +0,0 @@
import { useRef, useEffect, useMemo, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { usePhotoStore } from '../../store/photoStore'
import { PhotoThumbnail } from './PhotoThumbnail'
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
interface Photo {
id: string
filepath: string
filename: string
width: number | null
height: number | null
taken_at: string | null
rating: number
is_picked: boolean
is_rejected: boolean
file_hash: string
media_type: string
}
export function Timeline() {
const parentRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const {
selectedPhotos,
lastSelectedIndex,
rangeStartIndex,
selectPhoto,
togglePhotoSelection,
clearSelection,
} = usePhotoStore()
// Helper function for range selection
const selectRange = (endIndex: number) => {
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
const minIndex = Math.min(startIndex, endIndex)
const maxIndex = Math.max(startIndex, endIndex)
// Select all photos in the range
for (let i = minIndex; i <= maxIndex; i++) {
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
togglePhotoSelection(photos[i].id, i)
}
}
}
// Thumbnail size configuration
const thumbnailSize = 200 // Base size for thumbnails
const gap = 4
const padding = 16
// Calculate number of columns based on container width
const columns = useMemo(() => {
if (containerWidth === 0) return 4
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
}, [containerWidth, thumbnailSize, gap, padding])
// Fetch photos from backend
const { data: photos = [], isLoading } = useQuery({
queryKey: ['photos'],
queryFn: async () => {
const response = await axios.get<{photos: Photo[], total: number}>('http://localhost:8001/api/v1/photos', {
params: {
limit: 1000,
offset: 0,
},
})
return response.data.photos || []
},
staleTime: 30000,
})
// Group photos into rows for grid layout
const rows = useMemo(() => {
const result: Photo[][] = []
for (let i = 0; i < photos.length; i += columns) {
result.push(photos.slice(i, i + columns))
}
return result
}, [photos, columns])
// Virtual scrolling setup
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => thumbnailSize + gap,
overscan: 5,
})
// Measure container width on mount and resize
useEffect(() => {
const measureWidth = () => {
if (parentRef.current) {
setContainerWidth(parentRef.current.clientWidth)
}
}
measureWidth()
window.addEventListener('resize', measureWidth)
return () => window.removeEventListener('resize', measureWidth)
}, [])
// Handle keyboard shortcuts for photo navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (photos.length === 0) return
const currentIndex = lastSelectedIndex ?? -1
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
if (currentIndex > columns - 1) {
const newIndex = currentIndex - columns
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'ArrowDown':
e.preventDefault()
if (currentIndex < photos.length - columns) {
const newIndex = Math.min(currentIndex + columns, photos.length - 1)
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'ArrowLeft':
e.preventDefault()
if (currentIndex > 0) {
const newIndex = currentIndex - 1
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'ArrowRight':
e.preventDefault()
if (currentIndex < photos.length - 1) {
const newIndex = currentIndex + 1
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'a':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
// Select all
photos.forEach((photo, index) => {
if (!selectedPhotos.includes(photo.id)) {
togglePhotoSelection(photo.id, index)
}
})
}
break
case 'Escape':
e.preventDefault()
clearSelection()
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [photos, selectedPhotos, lastSelectedIndex, columns, selectPhoto, togglePhotoSelection, selectRange, clearSelection])
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-muted">Loading photos...</div>
</div>
)
}
if (photos.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-muted">No photos found. Add a source folder to get started.</div>
</div>
)
}
return (
<div
ref={parentRef}
className="h-full overflow-auto bg-bg"
style={{ padding: `${padding}px` }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div
className="flex"
style={{ gap: `${gap}px` }}
>
{row.map((photo, colIndex) => {
const globalIndex = virtualRow.index * columns + colIndex
return (
<PhotoThumbnail
key={photo.id}
photo={photo}
size={thumbnailSize}
isSelected={selectedPhotos.includes(photo.id)}
onClick={(e) => {
if (e.shiftKey && lastSelectedIndex !== null) {
selectRange(globalIndex)
} else if (e.ctrlKey || e.metaKey) {
togglePhotoSelection(photo.id, globalIndex)
} else {
selectPhoto(photo.id, globalIndex)
}
}}
/>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -1,55 +0,0 @@
import { useHotkeys } from 'react-hotkeys-hook'
interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void
onToggleRightSidebar: () => void
}
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
const { onToggleLeftSidebar, onToggleRightSidebar } = props
// Toggle sidebars
useHotkeys('tab', (e) => {
e.preventDefault()
onToggleLeftSidebar()
})
useHotkeys('i', (e) => {
e.preventDefault()
onToggleRightSidebar()
})
// Navigation shortcuts
useHotkeys('g', () => {
// Go to grid view
console.log('Grid view')
})
useHotkeys('e', () => {
// Go to loupe view
console.log('Loupe view')
})
// Rating shortcuts
useHotkeys('1,2,3,4,5', (_e, handler) => {
const rating = parseInt(handler.keys![0])
console.log('Set rating:', rating)
})
useHotkeys('0', () => {
console.log('Remove rating')
})
// Flag shortcuts
useHotkeys('p', () => {
console.log('Pick photo')
})
useHotkeys('x', () => {
console.log('Reject photo')
})
useHotkeys('u', () => {
console.log('Unflag photo')
})
}

View File

@@ -1,43 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-bg: #111110;
--color-surface: #161615;
--color-surface-2: #1c1c1a;
--color-surface-offset: #222220;
--color-border: rgba(255, 255, 255, 0.08);
--color-text: #e8e6e0;
--color-text-muted: #878580;
--color-text-faint: #4a4845;
--color-primary: #4f98a3;
--color-pick: #4f9e5c;
--color-reject: #c25a5a;
--color-star: #d4a340;
}
body {
@apply bg-bg text-text;
font-family: 'Geist', system-ui, sans-serif;
}
}
/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-surface;
}
::-webkit-scrollbar-thumb {
@apply bg-surface-offset rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-text-faint;
}

View File

@@ -1,24 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -1,186 +0,0 @@
import axios from 'axios'
const API_BASE_URL = 'http://localhost:8001/api/v1'
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Source Folders API
export const sourceFolders = {
list: async () => {
const response = await api.get('/folders')
return response.data
},
add: async (path: string, recursive: boolean = true) => {
const response = await api.post('/folders', {
path,
recursive,
watch: false, // Can be made configurable later
})
return response.data
},
scan: async (folderId: string) => {
const response = await api.post(`/folders/${folderId}/scan`)
return response.data
},
delete: async (folderId: string) => {
const response = await api.delete(`/folders/${folderId}`)
return response.data
},
}
// Photos API
export const photos = {
list: async (params?: {
limit?: number
offset?: number
folder_id?: string
heap_id?: string
rating?: number
flag?: string
}) => {
const response = await api.get('/photos', { params })
return response.data
},
get: async (photoId: string) => {
const response = await api.get(`/photos/${photoId}`)
return response.data
},
update: async (photoId: string, data: {
rating?: number
flag?: string
user_title?: string
user_notes?: string
}) => {
const response = await api.patch(`/photos/${photoId}`, data)
return response.data
},
bulkUpdate: async (photoIds: string[], data: {
rating?: number
flag?: string
heap_id?: string
trash?: boolean
}) => {
const response = await api.post('/photos/bulk', {
photo_ids: photoIds,
...data,
})
return response.data
},
getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
},
getOriginalUrl: (photoId: string) => {
return `${API_BASE_URL}/photos/${photoId}/original`
},
}
// Library API
export const library = {
scan: async () => {
const response = await api.post('/library/scan')
return response.data
},
scanStatus: async () => {
const response = await api.get('/library/scan/status')
return response.data
},
stats: async () => {
const response = await api.get('/library/stats')
return response.data
},
}
// Heaps API
export const heaps = {
list: async () => {
const response = await api.get('/heaps')
return response.data
},
create: async (name: string, description?: string) => {
const response = await api.post('/heaps', {
name,
description,
})
return response.data
},
update: async (heapId: string, data: {
name?: string
description?: string
}) => {
const response = await api.patch(`/heaps/${heapId}`, data)
return response.data
},
delete: async (heapId: string) => {
const response = await api.delete(`/heaps/${heapId}`)
return response.data
},
}
// Tags API
export const tags = {
list: async () => {
const response = await api.get('/tags')
return response.data
},
create: async (name: string, color?: string) => {
const response = await api.post('/tags', {
name,
color,
})
return response.data
},
update: async (tagId: string, data: {
name?: string
color?: string
}) => {
const response = await api.patch(`/tags/${tagId}`, data)
return response.data
},
delete: async (tagId: string) => {
const response = await api.delete(`/tags/${tagId}`)
return response.data
},
}
// Trash API
export const trash = {
list: async () => {
const response = await api.get('/trash')
return response.data
},
restore: async (photoIds: string[]) => {
const response = await api.post('/trash/restore', {
photo_ids: photoIds,
})
return response.data
},
empty: async () => {
const response = await api.delete('/trash/empty')
return response.data
},
}
export default api

View File

@@ -1,81 +0,0 @@
import { create } from 'zustand'
interface Photo {
id: string
filename: string
filepath: string
media_type: string
width?: number
height?: number
taken_at?: string
thumb_small?: string
thumb_medium?: string
thumb_large?: string
rating: number
is_picked: boolean
is_rejected: boolean
}
interface PhotoStore {
photos: Photo[]
selectedPhotos: string[]
activePhotoId: string | null
lastSelectedIndex: number | null
rangeStartIndex: number | null
setPhotos: (photos: Photo[]) => void
selectPhoto: (id: string, index: number) => void
togglePhotoSelection: (id: string, index: number) => void
selectRange: (endIndex: number) => void
deselectPhoto: (id: string) => void
clearSelection: () => void
setActivePhoto: (id: string | null) => void
}
export const usePhotoStore = create<PhotoStore>((set) => ({
photos: [],
selectedPhotos: [],
activePhotoId: null,
lastSelectedIndex: null,
rangeStartIndex: null,
setPhotos: (photos) => set({ photos }),
selectPhoto: (id, index) => set({
selectedPhotos: [id],
activePhotoId: id,
lastSelectedIndex: index,
rangeStartIndex: index,
}),
togglePhotoSelection: (id, index) => set((state) => {
const isSelected = state.selectedPhotos.includes(id)
return {
selectedPhotos: isSelected
? state.selectedPhotos.filter(photoId => photoId !== id)
: [...state.selectedPhotos, id],
lastSelectedIndex: index,
rangeStartIndex: isSelected ? state.rangeStartIndex : index,
}
}),
selectRange: (endIndex) => {
// Note: The actual range selection logic should be handled in the Timeline component
// which has access to the photos array
set({
lastSelectedIndex: endIndex,
})
},
deselectPhoto: (id) => set((state) => ({
selectedPhotos: state.selectedPhotos.filter(photoId => photoId !== id)
})),
clearSelection: () => set({
selectedPhotos: [],
lastSelectedIndex: null,
rangeStartIndex: null,
}),
setActivePhoto: (id) => set({ activePhotoId: id }),
}))

View File

@@ -1,29 +0,0 @@
declare module '*.png' {
const value: string;
export default value;
}
declare module '*.jpg' {
const value: string;
export default value;
}
declare module '*.jpeg' {
const value: string;
export default value;
}
declare module '*.gif' {
const value: string;
export default value;
}
declare module '*.svg' {
const value: string;
export default value;
}
declare module '*.webp' {
const value: string;
export default value;
}

View File

@@ -1,51 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Dark-first color scheme for photo apps
bg: '#111110',
surface: '#161615',
'surface-2': '#1c1c1a',
'surface-offset': '#222220',
border: 'rgba(255,255,255,0.08)',
text: '#e8e6e0',
'text-muted': '#878580',
'text-faint': '#4a4845',
primary: '#4f98a3', // desaturated teal
pick: '#4f9e5c', // green for picked
reject: '#c25a5a', // red for rejected
star: '#d4a340', // amber for stars
},
fontFamily: {
sans: ['Geist', 'system-ui', 'sans-serif'],
mono: ['Geist Mono', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.2s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'shimmer': 'shimmer 2s infinite linear',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
},
},
},
plugins: [],
}

View File

@@ -1,31 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,22 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

View File

@@ -0,0 +1,25 @@
-- Bootstraps the `mule_sidecar` database + user used by the Go sidecar
-- service (per-user heap sharing + folder mutations, stood up in M4).
--
-- MariaDB runs every .sql in /docker-entrypoint-initdb.d ONCE, on first
-- boot of a fresh data volume. Subsequent boots are no-ops.
--
-- The sidecar's MariaDB user is intentionally scoped to `mule_sidecar.*`
-- only — it never has access to PhotoPrism's schema.
CREATE DATABASE IF NOT EXISTS mule_sidecar
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
-- The password here is substituted at compose build time via envsubst,
-- but MariaDB's init script doesn't expand vars in .sql files. So we use
-- a literal placeholder that the user replaces locally — or, simpler,
-- we let the M4 sidecar bring-up script create the user via SQL with the
-- env-var password. Keeping a placeholder here makes the schema visible
-- in source control without leaking creds.
--
-- TODO (M4): replace this block with an entrypoint that templates the
-- password from $SIDECAR_DB_PASSWORD before MariaDB reads the file.
CREATE USER IF NOT EXISTS 'sidecar'@'%' IDENTIFIED BY 'replace-at-m4-bringup';
GRANT ALL PRIVILEGES ON mule_sidecar.* TO 'sidecar'@'%';
FLUSH PRIVILEGES;

View File

@@ -1,30 +0,0 @@
# Mulita configuration file
source_roots:
- name: "Main Library"
path: /photos/main
- name: "iPhone Imports"
path: /photos/iphone
thumbnails:
small: 240 # px, longest edge
medium: 640
large: 1280
quality: 85 # JPEG/WebP quality
format: webp # output format for thumbs
scanner:
watch: true # use watchfiles inotify
initial_scan_on_start: true
batch_size: 100
concurrent_workers: 4
trash:
path: /data/trash
auto_empty_days: 30 # auto-delete after 30 days in trash
performance:
max_concurrent_thumbnails: 10
cache_ttl: 3600
db_pool_size: 20
db_pool_recycle: 3600

View File

@@ -1,753 +0,0 @@
# PhotoVault — Full Application Spec Prompt
> A self-hosted, Docker-deployed photo management application inspired by Lightroom's workflow.
> Use this document as the complete specification to build the app from scratch.
---
## 1. Project Overview
Build **PhotoVault**, a self-hosted photo & video management web application optimized for a single-user homelab deployment. The user mounts one or more host folders containing photos/videos; the app indexes them, generates thumbnails, and provides a fast keyboard-driven interface to browse, organize, tag, and manage the library. The architecture must be forward-compatible with AI photo recognition features (face detection, scene classification, CLIP embeddings) to be added in a later phase.
---
## 2. Stack & Deployment
### 2.1 Docker Compose (single `docker-compose.yml`)
```
services:
frontend — React SPA (Nginx)
backend — Python FastAPI
db — SQLite (file-based, volume-mounted)
worker — Celery + Redis for background thumbnail/indexing tasks
redis — Redis (Celery broker)
```
All services declared in one `docker-compose.yml`. Use named volumes for:
- `/data/thumbs` — generated thumbnails (persistent)
- `/data/db` — SQLite database file
- `/data/trash` — files moved to trash
Photo source folders are mounted as **read-write** bind mounts via an environment variable:
```yaml
volumes:
- ${PHOTO_DIRS}:/photos:rw
```
`PHOTO_DIRS` supports multiple paths via a config file (`photovault.yml`) described in §4.
### 2.2 Frontend
- **React 18** + **Vite**
- **Tailwind CSS v4**
- **shadcn/ui** component library
- **TanStack Query** (React Query) for data fetching & cache
- **TanStack Virtual** for virtualized scrolling (critical for performance with thousands of photos)
- **Zustand** for global UI state (selection, active photo, heap, filters)
- **Framer Motion** for transitions
### 2.3 Backend
- **Python 3.12 + FastAPI**
- **SQLite** via **SQLAlchemy 2.0** (async) + **Alembic** for migrations
- **Celery + Redis** for background tasks (thumbnail generation, folder scanning, metadata extraction)
- **pyvips** (libvips) for fast thumbnail generation — preferred over Pillow for speed at scale
- **rawpy** for RAW format decoding (CR2, CR3, NEF, ARW, RAF, DNG, ORF, RW2, etc.)
- **pillow-heif** for HEIC/HEIF (iPhone photos)
- **ffmpeg** (via `ffmpeg-python`) for video thumbnail extraction and metadata
- **pyexiftool** (wraps ExifTool binary) for deep metadata extraction from all formats
- **Watchfiles** for inotify-based folder watching (auto-detect new/deleted files)
> **AI-readiness note**: The backend worker architecture is designed to add a `clip_embed` task later (using `open-clip-torch`) that stores 512-dim CLIP embeddings per photo in the DB. Reserve a `embeddings` table with a `photo_id` FK and a `BLOB` column for the vector. No AI code yet — just the schema placeholder.
---
## 3. Data Model (SQLite via SQLAlchemy)
```sql
-- Core tables
photos (
id TEXT PRIMARY KEY, -- UUID
filepath TEXT UNIQUE NOT NULL,
filename TEXT NOT NULL,
folder_id TEXT REFERENCES folders(id),
media_type TEXT NOT NULL, -- 'photo' | 'video' | 'raw' | 'heic'
original_format TEXT, -- 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.
width INTEGER,
height INTEGER,
file_size INTEGER,
taken_at DATETIME, -- from EXIF DateTimeOriginal, fallback to file mtime
taken_at_source TEXT, -- 'exif' | 'filesystem' | 'manual'
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
is_trashed BOOLEAN DEFAULT 0,
trashed_at DATETIME,
thumb_small TEXT, -- path to 240px thumb
thumb_medium TEXT, -- path to 640px thumb
thumb_large TEXT, -- path to 1280px thumb
exif_json TEXT, -- full EXIF/XMP blob as JSON
user_title TEXT, -- user-edited title
user_notes TEXT,
rating INTEGER DEFAULT 0, -- 0-5 stars
color_label TEXT, -- 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
is_picked BOOLEAN DEFAULT 0,
is_rejected BOOLEAN DEFAULT 0
)
folders (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT UNIQUE NOT NULL,
parent_id TEXT REFERENCES folders(id),
source_root_id TEXT REFERENCES source_roots(id),
photo_count INTEGER DEFAULT 0,
last_scanned DATETIME
)
source_roots (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT UNIQUE NOT NULL,
is_active BOOLEAN DEFAULT 1,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
tags (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
color TEXT
)
photo_tags (
photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE,
tag_id TEXT REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (photo_id, tag_id)
)
heaps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME
)
heap_photos (
heap_id TEXT REFERENCES heaps(id) ON DELETE CASCADE,
photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
sort_order INTEGER DEFAULT 0,
PRIMARY KEY (heap_id, photo_id)
)
-- AI-readiness placeholder (no implementation yet)
embeddings (
photo_id TEXT PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE,
model TEXT, -- e.g. 'clip-vit-b32'
vector BLOB -- raw float32 bytes
)
```
**Indexes**: Create indexes on `photos.taken_at`, `photos.folder_id`, `photos.is_trashed`, `photos.rating`, `photos.color_label`, `photo_tags.tag_id`.
---
## 4. Configuration
App is configured via a `photovault.yml` file mounted into the backend container:
```yaml
source_roots:
- name: "Main Library"
path: /photos/main
- name: "iPhone Imports"
path: /photos/iphone
thumbnails:
small: 240 # px, longest edge
medium: 640
large: 1280
quality: 85 # JPEG quality
format: webp # output format for thumbs
scanner:
watch: true # use watchfiles inotify
initial_scan_on_start: true
trash:
path: /data/trash
```
---
## 5. Backend API (FastAPI)
All routes under `/api/v1/`. Authentication: none (single-user, homelab). Use async SQLAlchemy sessions.
### 5.1 Photos
```
GET /photos List photos (pagination + filters — see §5.5)
GET /photos/{id} Get single photo with full EXIF + tags
GET /photos/{id}/thumb/{size} Serve thumbnail (small|medium|large) — use X-Accel-Redirect for Nginx
GET /photos/{id}/original Serve original file (download)
PATCH /photos/{id} Update: user_title, user_notes, rating, color_label, is_picked, is_rejected, taken_at (manual override)
DELETE /photos/{id} Move to trash (sets is_trashed=1, moves file to /data/trash)
POST /photos/bulk Bulk actions: { ids: [], action: 'trash'|'restore'|'delete_permanent'|'move'|'copy'|'add_tag'|'remove_tag'|'set_rating'|'set_color'|'pick'|'reject' }
POST /photos/bulk/move Move files to a target folder_id
POST /photos/bulk/copy Copy files to a target folder_id
```
### 5.2 Folders
```
GET /folders Folder tree (nested, with photo_count)
GET /folders/{id}/photos Photos in folder (supports same filters as /photos)
POST /folders Create folder (creates directory on disk)
PATCH /folders/{id} Rename folder (renames directory on disk)
DELETE /folders/{id} Delete folder — requires folder to be empty
POST /folders/{id}/scan Trigger manual re-scan of folder
```
### 5.3 Heaps
```
GET /heaps List all heaps
POST /heaps Create heap { name }
GET /heaps/{id} Get heap with photos
PATCH /heaps/{id} Rename heap
DELETE /heaps/{id} Delete heap (does NOT delete photos)
POST /heaps/{id}/photos Add photos { photo_ids: [] }
DELETE /heaps/{id}/photos Remove photos { photo_ids: [] }
POST /heaps/{id}/convert Convert heap to folder on disk: { target_path, move: bool }
```
### 5.4 Tags
```
GET /tags List all tags with usage counts
POST /tags Create tag
PATCH /tags/{id} Rename / recolor tag
DELETE /tags/{id} Delete tag (removes from all photos)
GET /tags/{id}/photos Photos with this tag
```
### 5.5 Filters & Search
All list endpoints support these query parameters:
```
q Full-text search (filename, user_title, user_notes, EXIF JSON)
date_from ISO8601 datetime
date_to ISO8601 datetime
folder_id Filter by folder (recursive if include_subfolders=true)
tag_ids Comma-separated tag IDs (AND logic by default; mode=or for OR)
media_type photo|video|raw|heic (comma-separated for multiple)
rating_min 0-5
rating_max 0-5
color_label red|orange|yellow|green|blue|purple|none
is_picked true|false
is_rejected true|false
is_trashed true|false (default false)
heap_id Filter to photos in a specific heap
sort taken_at|added_at|filename|file_size|rating (default taken_at)
order asc|desc (default desc)
page integer (default 1)
per_page integer (default 100, max 500)
```
Full-text search uses SQLite FTS5. Create a virtual FTS table:
```sql
CREATE VIRTUAL TABLE photos_fts USING fts5(
photo_id UNINDEXED,
filename,
user_title,
user_notes,
exif_text -- denormalized key EXIF fields as plain text (camera make/model, GPS, lens, etc.)
);
```
### 5.6 Trash
```
GET /trash List trashed photos (same filters)
POST /trash/restore Restore { photo_ids: [] } — moves files back to original folder
DELETE /trash/empty Permanently delete all trashed photos + files
DELETE /trash/{id} Permanently delete single photo + file
```
### 5.7 Library Stats & Scanning
```
GET /library/stats { total_photos, total_videos, total_size, last_scan }
POST /library/scan Trigger full re-scan (Celery task)
GET /library/scan/status { status, progress, current_folder, queued, done }
```
### 5.8 Background Tasks (Celery)
- `scan_folder(folder_path)` — Walk directory tree, insert/update photos, detect deletions
- `generate_thumbs(photo_id)` — Generate small/medium/large WebP thumbnails via pyvips/rawpy/ffmpeg
- `extract_metadata(photo_id)` — Run ExifTool, parse EXIF/XMP/IPTC, update DB
- `watch_folders()` — Long-running Watchfiles task, dispatches scan_folder on changes
- `embed_photo(photo_id)` *(placeholder, no-op)* — Reserved for CLIP embeddings
**Priority queues**: Thumbnail generation for visible photos should be on a `high` queue; full library scans on a `low` queue.
---
## 6. Frontend Architecture
### 6.1 Layout
Three-pane layout (similar to Lightroom Library module):
```
┌─────────────────────────────────────────────────────────────┐
│ TOP BAR [Logo] [Search] [Filters bar] [View mode] [Heap] │
├──────────┬──────────────────────────────────┬───────────────┤
│ │ │ │
│ LEFT │ MAIN TIMELINE │ RIGHT │
│ SIDEBAR │ (continuous scroll, │ SIDEBAR │
│ │ sticky date headers, │ (metadata │
│ Folder │ virtualized thumbnail │ panel for │
│ tree │ grid) │ selected │
│ │ │ photo) │
│ Heaps │ │ │
│ list │ │ │
│ │ │ │
│ Tags │ │ │
└──────────┴──────────────────────────────────┴───────────────┘
```
- Left sidebar: resizable, collapsible (shortcut: `Tab`)
- Right sidebar: collapsible (shortcut: `I`), shows when ≥1 photo selected
- Main area: full virtualized scroll, single scroll region
### 6.2 Views
| View | Shortcut | Description |
|------|----------|-------------|
| Grid (Library) | `G` | Default timeline thumbnail grid |
| Loupe (Fullscreen) | `E` | Single photo full-viewport view |
| Compare | `C` | Side-by-side compare of 2 selected photos |
### 6.3 Timeline View (Grid)
- **Continuous vertical scroll** with **sticky date headers** that label each date group (Year / Month / Day — configurable via a "Group by" dropdown: Year, Month, Day, Week, Folder)
- Thumbnails rendered via **TanStack Virtual** — only DOM nodes in/near viewport are rendered
- Thumbnail grid is **responsive** — uses CSS grid with `auto-fill` and configurable thumbnail size (slider or `+/-` keys)
- Thumbnails show: image, hover overlay with filename, EXIF date, optional rating stars
- **Lazy thumbnail loading**: request `thumb_small` initially; upgrade to `thumb_medium` on hover/selection
- On initial scan, show a shimmer skeleton for photos without thumbnails yet; poll backend for thumb completion
### 6.4 Keyboard Shortcuts (Lightroom-style)
#### Navigation (Grid mode)
| Key | Action |
|-----|--------|
| `←` `→` `↑` `↓` | Move cursor one photo in direction |
| `Shift+←/→/↑/↓` | Extend selection |
| `Cmd/Ctrl+A` | Select all |
| `Cmd/Ctrl+Shift+A` | Deselect all |
| `Space` | Quick preview (fullscreen loupe, hold) |
| `Enter` | Open loupe view |
| `Esc` | Deselect / close loupe |
| `Home` / `End` | Jump to first / last photo |
| `Page Up/Down` | Scroll by screen height |
#### Navigation (Loupe mode)
| Key | Action |
|-----|--------|
| `←` `→` | Previous / next photo |
| `Esc` | Return to grid |
| `Z` | Toggle zoom (fit ↔ 100%) |
| `+` / `-` | Zoom in / out |
#### Flagging & Rating
| Key | Action |
|-----|--------|
| `P` | Pick (flag) |
| `X` | Reject |
| `U` | Unflag |
| `15` | Set star rating |
| `0` | Remove star rating |
| `6` | Red label |
| `7` | Orange label |
| `8` | Yellow label |
| `9` | Green label |
#### Actions
| Key | Action |
|-----|--------|
| `G` | Go to grid view |
| `E` | Go to loupe view |
| `C` | Compare view (2 selected) |
| `Tab` | Toggle left sidebar |
| `I` | Toggle right metadata sidebar |
| `\` | Toggle filter bar |
| `F` | Toggle fullscreen |
| `Delete` | Move selected to trash |
| `Shift+Delete` | Permanently delete (if in trash view) |
| `Cmd/Ctrl+Z` | Undo last action |
| `Cmd/Ctrl+Shift+Z` | Redo |
| `Cmd/Ctrl+C` | Copy selected to clipboard (for move/copy target) |
| `Cmd/Ctrl+X` | Cut selected (for move) |
| `Cmd/Ctrl+V` | Paste into current folder |
| `T` | Add/remove from active heap |
| `Cmd/Ctrl+F` | Focus search bar |
| `/` | Focus search bar |
| `?` | Show keyboard shortcut reference overlay |
All shortcuts must work without modifier unless noted. Shortcuts must be suppressed when focus is inside an input/textarea.
### 6.5 Bulk Selection
- **Click** — select single photo (deselects others)
- **Shift+Click** — range select from last selected to clicked
- **Cmd/Ctrl+Click** — toggle individual photo in selection
- **Cmd/Ctrl+A** — select all visible
- A **selection bar** appears at the top of the main area when ≥2 photos selected, showing count and bulk action buttons: Rate, Color Label, Tag, Add to Heap, Move, Copy, Trash, Export
- Bulk actions call `POST /api/v1/photos/bulk`
### 6.6 Metadata Sidebar (Right Panel)
When a photo is selected, the right sidebar shows:
**Section: Preview**
- Large thumbnail (clicking opens loupe)
- Filename (editable inline, renames file on disk)
- User title (editable)
- User notes (textarea)
- Rating (5-star widget, keyboard-interactive)
- Color label (color dot picker)
- Flags: Picked / Rejected toggles
**Section: Tags**
- Tag chips with remove button
- "Add tag" autocomplete input
- Create new tag inline
**Section: EXIF / Metadata**
Collapsible groups:
- *Camera*: Make, Model, Serial, Lens, Firmware
- *Capture*: Date Taken (editable override), Shutter Speed, Aperture, ISO, Focal Length, EV, Flash, White Balance, Metering Mode
- *File*: Format, Dimensions, File Size, Color Space, Bit Depth
- *Location*: GPS lat/lon shown on a small Leaflet.js map tile if available; altitude, country, city (reverse-geocoded via nominatim.openstreetmap.org on demand)
- *IPTC/XMP*: Copyright, Creator, Description, Keywords
**Section: Histogram** (stretch goal)
- Live RGB+Luminosity histogram rendered from a downsampled version of the photo
### 6.7 Filter Bar
A collapsible horizontal bar below the top bar (shortcut `\`). Contains:
| Control | Type |
|---------|------|
| Date range | Date range picker (from/to) |
| Media type | Multi-select chips: Photo / Video / RAW / HEIC |
| Rating | Min/max star slider |
| Color label | Color dot multi-select |
| Flags | Picked / Rejected / Unflagged toggle buttons |
| Tags | Multi-select tag dropdown (AND/OR mode toggle) |
| Camera make | Dropdown (populated from DB) |
| Lens | Dropdown (populated from DB) |
Active filters shown as removable chips in the filter bar. "Clear all" button. Filter state persists in URL query params for shareability/bookmarks.
### 6.8 Search
- Magnifier icon in top bar, shortcut `/` or `Cmd+F`
- Full-text search via FTS5 backend
- Search covers: filename, user title, user notes, camera make/model, lens, GPS place names, tags
- Results appear inline in the current view (no separate search results page)
- Search combined with active filters (additive)
### 6.9 Folder Tree (Left Sidebar)
- Hierarchical tree view of all source roots and their subfolder structure
- Each folder shows photo count badge
- Right-click context menu: New Subfolder, Rename, Move Photos Here, Scan Now, Copy Path
- Drag-and-drop folders to rearrange (moves directory on disk with confirmation)
- "All Photos" virtual root node at top
- "Trash" virtual node at bottom with count badge
### 6.10 Heaps Panel (Left Sidebar)
- List of named heaps below folder tree
- "+ New Heap" button (creates unnamed heap, prompts for name)
- Each heap shows photo count
- Click heap → main area shows heap contents in grid
- Right-click context menu: Rename, Convert to Folder (prompts for target path + move/copy choice), Delete Heap, Clear Heap
- **Active Heap indicator**: One heap can be set as "active" (bold + icon). Pressing `T` adds/removes the selected photo(s) from the active heap.
- A persistent "current heap" pill shown in the top bar when a heap is active
### 6.11 Loupe View
- Single photo, full-viewport
- Original-quality image (served from backend, format-agnostic — backend transcodes RAW/HEIC to JPEG/WebP on the fly for web display)
- Zoom: fit-to-window ↔ 100% (toggle `Z`), scroll wheel to zoom, drag to pan at 100%+
- Filmstrip at bottom: horizontally scrollable strip of thumbnails (current context — same folder or heap), keyboard navigable
- Left panel collapse, right metadata panel still accessible
- For videos: HTML5 `<video>` player with controls, muted autoplay of preview, unmute toggle
### 6.12 Trash View
- Accessible via "Trash" node in sidebar
- Same grid layout, same filters, same shortcuts
- Extra actions in bulk selection bar: Restore, Permanently Delete
- "Empty Trash" button at top with confirmation dialog showing count + total size
### 6.13 Library Scan Progress
- On first launch or manual scan trigger: a non-blocking progress bar in the top bar
- Shows: `Scanning… 1,234 / 12,456 photos indexed`
- Photos appear in the timeline as they are indexed (optimistic streaming via polling `GET /library/scan/status` every 2s)
---
## 7. Media Handling
### 7.1 Supported Formats
| Category | Formats |
|----------|---------|
| JPEG | `.jpg`, `.jpeg` |
| PNG | `.png` |
| TIFF | `.tif`, `.tiff` |
| WebP | `.webp` |
| HEIC/HEIF | `.heic`, `.heif` (via pillow-heif) |
| RAW — Canon | `.cr2`, `.cr3` |
| RAW — Nikon | `.nef`, `.nrw` |
| RAW — Sony | `.arw`, `.srf` |
| RAW — Fuji | `.raf` |
| RAW — Panasonic | `.rw2` |
| RAW — Olympus | `.orf` |
| RAW — Samsung | `.srw` |
| RAW — Pentax | `.pef` |
| RAW — Leica | `.rwl`, `.dng` |
| RAW — DNG (universal) | `.dng` |
| RAW — Others | via rawpy (libraw) fallback |
| Video | `.mp4`, `.mov`, `.avi`, `.mkv`, `.mts`, `.m2ts`, `.3gp` |
| Live Photos | `.heic` + `.mov` pair (detect by matching base filename) |
### 7.2 Thumbnail Generation Pipeline
For each photo during indexing:
1. Detect format by extension + magic bytes
2. Decode to in-memory RGB image:
- JPEG/PNG/TIFF/WebP → pyvips native
- HEIC/HEIF → pillow-heif → pyvips
- RAW → rawpy (half-size decode for speed) → numpy → pyvips
- Video → ffmpeg extract frame at 10% duration → pyvips
3. Auto-rotate by EXIF orientation
4. Generate 3 sizes: 240px, 640px, 1280px (longest edge, maintain AR)
5. Save as WebP (quality 85) to `/data/thumbs/{photo_id}/{size}.webp`
6. Update `thumb_small`, `thumb_medium`, `thumb_large` columns in DB
For web display of original RAW/HEIC in loupe view: generate a full-res WebP proxy on demand (cached). Serve via `GET /photos/{id}/proxy`.
### 7.3 Metadata Extraction
Run ExifTool (subprocess) on every file during indexing. Parse output JSON. Store:
- `taken_at` — prefer `DateTimeOriginal`, fallback: `CreateDate`, `MediaCreateDate`, file mtime
- GPS coordinates if present
- All EXIF/IPTC/XMP fields stored as JSON in `exif_json`
- Denormalize key fields to FTS table for search
For Live Photos: link the `.mov` sidecar to the `.heic` via a `live_photo_video_id` FK on the photos table.
---
## 8. File Operations
All file operations that touch disk must:
1. Validate target path is within a known source_root (prevent path traversal)
2. Execute atomically where possible (temp file + rename)
3. Update DB after successful disk operation (never before)
4. Emit a WebSocket event (or SSE) so the frontend can update optimistically
5. Be undoable via Undo stack (store reverse operation in memory, max 50 ops)
### Operations
| Operation | Disk action | DB action |
|-----------|-------------|-----------|
| Move photos | `shutil.move` | Update `filepath`, `folder_id` |
| Copy photos | `shutil.copy2` | Insert new photo record |
| Rename file | `os.rename` | Update `filepath`, `filename` |
| Rename folder | `os.rename` | Update folder `path` recursively |
| Create folder | `os.makedirs` | Insert folder record |
| Trash photo | Move to `/data/trash/{id}/original.{ext}` | Set `is_trashed=1`, `trashed_at` |
| Restore from trash | Move back to original path (or new path if original gone) | Clear `is_trashed` |
| Permanent delete | `os.unlink` | Delete photo record (cascade to tags, heaps) |
| Convert heap to folder | `os.makedirs(target)` + move/copy each photo | Insert folder, update photo folder_id |
---
## 9. Performance Requirements
- **Initial page load**: < 2s (LCP)
- **Timeline scroll** (10,000+ photos): 60 fps — enforced by TanStack Virtual (only ~20-30 DOM nodes rendered at any time)
- **Thumbnail serve**: < 50ms via Nginx X-Accel-Redirect (backend sets header, Nginx serves file directly)
- **Search**: < 200ms for FTS5 query on 100k photos
- **Thumbnail generation**: ≥ 10 photos/sec on typical homelab CPU (pyvips is ~10x faster than Pillow)
- **Scan throughput**: ≥ 500 files/sec metadata scan (ExifTool batch mode processes files in bulk)
- **Celery workers**: 4 concurrent workers by default (`CELERYD_CONCURRENCY=4` env var)
- Images not yet thumbnailed show a shimmer skeleton; thumbnails stream into view as they complete
---
## 10. UI Design System
### 10.1 Aesthetic
Dark-first application (photography tools are dark-themed to preserve color perception). Light mode available via toggle.
- **Dark mode primary surface**: Near-black warm dark `#111110`, not cold gray
- **Accent**: Desaturated teal — does not compete with photo colors
- **Typography**: `Geist` (body, UI chrome) + `Geist Mono` (metadata values, EXIF numbers)
- Dense UI — this is a power tool, not a consumer app. Compact spacing.
- Inspired by: Lightroom Classic, Linear, Darkroom (iOS)
### 10.2 Key UI Components (shadcn/ui)
Use these shadcn/ui primitives: `Button`, `ContextMenu`, `Dialog`, `DropdownMenu`, `Input`, `Label`, `Popover`, `ScrollArea`, `Separator`, `Sheet` (for mobile sidebar), `Skeleton`, `Slider`, `Switch`, `Tabs`, `Textarea`, `Toast`, `Tooltip`
Build custom components:
- `<PhotoThumbnail>` — thumbnail with selection state, pick/reject badges, rating overlay on hover
- `<TimelineGroup>` — sticky date header + grid of thumbnails
- `<VirtualTimeline>` — TanStack Virtual wrapper over TimelineGroups
- `<FilmStrip>` — horizontal scrollable strip for loupe view
- `<StarRating>` — interactive 0-5 stars
- `<ColorLabel>` — 7-state color dot picker
- `<MetadataRow>` — label + value pair with edit-in-place for editable fields
- `<FolderTreeNode>` — recursive folder tree item with context menu
- `<HeapItem>` — heap list item with active indicator
- `<FilterChip>` — removable active filter chip
- `<ProgressBar>` — scan progress in top bar
- `<ShortcutReference>``?` overlay showing all shortcuts in a modal
### 10.3 Color Scheme Variables
```css
/* Dark mode (default for photo apps) */
--color-bg: #111110;
--color-surface: #161615;
--color-surface-2: #1c1c1a;
--color-surface-offset: #222220;
--color-border: rgba(255,255,255,0.08);
--color-text: #e8e6e0;
--color-text-muted: #878580;
--color-text-faint: #4a4845;
--color-primary: #4f98a3; /* desaturated teal */
--color-pick: #4f9e5c; /* green for picked */
--color-reject: #c25a5a; /* red for rejected */
--color-star: #d4a340; /* amber for stars */
```
---
## 11. Error States & Edge Cases
- **File not found on disk** (moved externally): Show "missing file" badge on thumbnail. Offer "Locate File" action.
- **Corrupt/unreadable file**: Log error, show broken-image placeholder, never crash the scan worker.
- **Duplicate detection**: On scan, if a file with the same SHA-256 hash already exists in DB, mark as `is_duplicate=true` — do not create a second record. Show duplicate indicator in thumbnail.
- **Scan in progress + user navigates**: Show partial results immediately as photos are indexed.
- **Disk full**: Catch `OSError` on thumbnail write, log, continue scan.
- **RAW decode failure**: Fall back to extracting the embedded JPEG preview from the RAW file (ExifTool can extract it).
---
## 12. Stretch Goals (Phase 2 — Not in Initial Build)
These must not be built now but the architecture must not block them:
1. **AI Scene Classification** — CLIP embeddings per photo, semantic search ("find photos with mountains")
2. **Face Detection & Clustering** — face_recognition lib or InsightFace, cluster by identity
3. **Smart Albums** — saved filter presets that auto-populate (e.g., "5-star Canon shots from 2024")
4. **Duplicate Finder** — perceptual hash (pHash) across library
5. **Export Presets** — resize + watermark + format conversion on export
6. **Multi-user** — add FastAPI auth (JWT), per-user libraries
7. **Mobile PWA** — service worker, offline thumbnail caching
---
## 13. Docker Compose File Structure
```
photovault/
├── docker-compose.yml
├── photovault.yml ← user config
├── .env ← PHOTO_DIRS, REDIS_URL, etc.
├── frontend/
│ ├── Dockerfile
│ ├── package.json
│ ├── vite.config.ts
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── store/ ← Zustand stores
│ ├── components/
│ │ ├── layout/
│ │ ├── timeline/
│ │ ├── loupe/
│ │ ├── sidebar/
│ │ ├── metadata/
│ │ └── shared/
│ ├── hooks/
│ ├── api/ ← TanStack Query hooks + axios client
│ └── lib/
│ └── shortcuts.ts ← global keyboard shortcut registry
└── backend/
├── Dockerfile
├── requirements.txt
├── alembic/
├── app/
│ ├── main.py ← FastAPI app
│ ├── config.py ← pydantic settings
│ ├── database.py ← SQLAlchemy async engine
│ ├── models/ ← SQLAlchemy ORM models
│ ├── schemas/ ← Pydantic request/response schemas
│ ├── routers/ ← FastAPI routers per domain
│ │ ├── photos.py
│ │ ├── folders.py
│ │ ├── heaps.py
│ │ ├── tags.py
│ │ ├── trash.py
│ │ └── library.py
│ ├── services/ ← Business logic
│ │ ├── scanner.py
│ │ ├── thumbnailer.py
│ │ ├── metadata.py
│ │ └── file_ops.py
│ └── tasks/ ← Celery tasks
│ ├── celery.py
│ ├── scan.py
│ └── thumbs.py
└── nginx.conf ← X-Accel-Redirect for thumb serving
```
---
## 14. Implementation Priorities
Build in this order to get a working MVP as fast as possible:
1. **Docker Compose skeleton** — all services up, health checks passing
2. **DB schema + Alembic migration**
3. **Folder scanner + thumbnail generator** (Celery tasks) — the core engine
4. **`GET /photos` + `GET /photos/{id}/thumb/{size}`** — minimum API to display photos
5. **Frontend: VirtualTimeline + PhotoThumbnail** — display the library
6. **Frontend: keyboard navigation + selection**
7. **Frontend: left sidebar (folder tree + heaps)**
8. **Frontend: right sidebar (metadata panel) + EXIF display**
9. **Filter bar + search**
10. **Loupe view with filmstrip**
11. **File operations: move, copy, rename, trash, restore**
12. **Metadata editing: title, notes, rating, color label, tags**
13. **Heaps: create, populate, convert to folder**
14. **Trash view + permanent delete**
15. **Polish: undo/redo, bulk actions, duplicate detection, live scan progress**

13
sidecar/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
# Files that have no business shipping into the build context.
# Anything not listed here gets COPY'd into /src so keep this tight.
# Local host-mode build output — re-built inside the image.
mule-sidecar
# Runtime state from the M3 Node prototype.
data/
# Docs + git noise.
README.md
.git/
.gitignore

28
sidecar/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1.6
#
# mule-sidecar — Go service for endpoints PhotoPrism does not expose.
# Multi-stage build: a Go toolchain image compiles a static binary,
# then we copy it onto a distroless base so the runtime image is ~12 MB
# with no shell, package manager, or libc.
FROM docker.io/library/golang:1.25-alpine AS build
WORKDIR /src
# Cache deps separately from source so a one-line code change doesn't
# re-download the whole module graph.
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
# CGO disabled → fully static binary that runs on the distroless base
# (no libc resolution at startup). -trimpath strips local paths from
# debug info; -s -w drop the symbol table to keep the binary small.
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags='-s -w' \
-o /out/mule-sidecar .
FROM gcr.io/distroless/static-debian12:latest
COPY --from=build /out/mule-sidecar /mule-sidecar
EXPOSE 8000
ENTRYPOINT ["/mule-sidecar"]

116
sidecar/README.md Normal file
View File

@@ -0,0 +1,116 @@
# mule-sidecar
Go + Gin + GORM service for the endpoints PhotoPrism's REST API does not
expose. Same wire contract as the M3 Node prototype it replaces; the
SvelteKit client at [web/](../web/) talks to it transparently through
Vite's `/api/sidecar/*` proxy.
## What it owns
| Method | Path | Purpose |
| ------ | --------------------------------------- | --------------------------------------------- |
| GET | `/api/sidecar/healthz` | Unauthenticated liveness probe. |
| GET | `/api/sidecar/photos/marks` | Every per-photo `{rating, color}` mark. |
| GET | `/api/sidecar/photos/:uid/marks` | One photo's mark (or `{}` if none). |
| PUT | `/api/sidecar/photos/:uid/marks` | Patch one photo's mark. |
| POST | `/api/sidecar/photos/marks/bulk` | Stamp the same mark onto many photos. |
| POST | `/api/sidecar/files/:uid/rename` | Rename the primary file on disk + reindex. |
| POST | `/api/sidecar/folders` | Create a folder under `${ORIGINALS_ROOT}`. |
| POST | `/api/sidecar/folders/:rel/rename` | Rename a folder (rel path URL-encoded). |
| DELETE | `/api/sidecar/folders/:rel` | Delete an **empty** folder. |
| POST | `/api/sidecar/albums/:uid/convert` | Move/copy every photo in a heap into folder X. |
| GET | `/api/sidecar/duplicates/scan` | Walk originals, return same-hash groups. |
| POST | `/api/sidecar/duplicates/archive` | Move duplicate paths into `.duplicates/<ts>/`. |
Auth: every endpoint except `healthz` requires the caller's
`X-Auth-Token` header. The sidecar holds no service credentials — it
proxies the token straight back to PhotoPrism's `/api/v1/photos?count=1`
to confirm the session is live before doing anything destructive.
Marks persist to **MariaDB** (`mule_sidecar.marks`); everything else
operates on the filesystem under `${ORIGINALS_ROOT}` and triggers a
PhotoPrism reindex of the affected parent in the background.
## Run
The sidecar is a `sidecar` service in the PhotoPrism compose stack.
Bringing the whole stack up brings it up too:
```sh
podman-compose --env-file .env \
-f docker-compose.yml \
-f docker-compose.podman.yml \
up -d
```
This builds [Dockerfile](Dockerfile) (multi-stage `golang:1.25-alpine`
`gcr.io/distroless/static`, ~12 MB final image), starts the container,
and binds `127.0.0.1:8000` to the service. The SvelteKit dev server
proxies `/api/sidecar/*` to that port transparently.
### Dev-iteration loop (host build)
For tight iteration without rebuilding the image on every change you
can run it as a host process — Go is already on the dev machine:
```sh
cd sidecar
go build -o mule-sidecar .
ORIGINALS_ROOT=/path/to/photoprism/originals \
PHOTOPRISM_BASE_URL=http://localhost:2342 \
SIDECAR_PORT=8000 \
./mule-sidecar
```
The host build connects to `mariadb` via the loopback port the compose
file publishes; stop `pp-sidecar` first so they don't fight for 8000.
## Env
| Var | Default | Notes |
| --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
| `ORIGINALS_ROOT` | `/photoprism/originals` | Absolute path; must match PhotoPrism's mount. |
| `PHOTOPRISM_BASE_URL` | `http://localhost:2342` | Where to reach PhotoPrism for session validation + reindex calls. |
| `SIDECAR_PORT` | `8000` | Loopback-only; reverse-proxy fronts it in production. |
| `SIDECAR_DSN` | _(built from the vars below)_ | Set this to override the assembled MySQL DSN entirely. |
| `SIDECAR_DB_HOST` | `127.0.0.1` | Host of the MariaDB the compose stack publishes on `127.0.0.1:3306`. |
| `SIDECAR_DB_PORT` | `3306` | |
| `SIDECAR_DB_USER` | `sidecar` | Provisioned by [`mariadb/init/01-sidecar.sql`](../mariadb/init/01-sidecar.sql) on first boot. |
| `SIDECAR_DB_PASSWORD` | `replace-at-m4-bringup` | Literal placeholder — **rotate before any non-local deployment**. |
| `SIDECAR_DB_NAME` | `mule_sidecar` | |
## Schema
GORM `AutoMigrate` creates the only table the service owns:
```sql
CREATE TABLE marks (
photo_uid VARCHAR(64) PRIMARY KEY,
rating BIGINT NULL,
color VARCHAR(16) NULL,
updated_at DATETIME(3)
);
```
The M3 Node prototype kept the same data in `sidecar/data/marks.json`.
There is no migration path — the prototype's marks file was dev-only
state. Heap-sharing tables (M4) will land in subsequent migrations.
## Layout
```text
sidecar/
├── Dockerfile multi-stage golang:1.25 → distroless/static
├── main.go entrypoint, route wiring, graceful shutdown
├── config.go env-driven Config
├── db.go GORM open + Mark model + AutoMigrate
├── auth.go requireSession middleware + ctxToken
├── fs.go path safety, walk, sha1
├── pp.go PhotoPrism HTTP client (validateSession, reindex)
├── handlers_rename.go
├── handlers_folders.go
├── handlers_marks.go
├── handlers_heap.go
└── handlers_dups.go
```

87
sidecar/auth.go Normal file
View File

@@ -0,0 +1,87 @@
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// requireSession is the standard auth shim every mutating handler wears.
// We don't store a shared service credential — the caller's X-Auth-Token
// is the only authority, and we probe PhotoPrism with it before doing any
// destructive work. The handler reads the validated token off the context
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual
// operation. The resolved username is available via ctxUserName.
func requireSession(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("X-Auth-Token")
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
return
}
user := pp.resolveSession(c.Request.Context(), token)
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
return
}
c.Set("token", token)
c.Set("userName", user.UserName)
c.Set("userUID", user.UserUID)
c.Set("basePath", user.BasePath)
c.Next()
}
}
// ctxToken returns the validated X-Auth-Token a previous requireSession
// middleware stored on the request. Handlers MUST run behind that
// middleware; otherwise this returns the empty string.
func ctxToken(c *gin.Context) string {
v, ok := c.Get("token")
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// ctxUserName returns the PhotoPrism username resolved by requireSession.
func ctxUserName(c *gin.Context) string {
v, ok := c.Get("userName")
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// ctxUserUID returns the PhotoPrism user UID resolved by requireSession.
func ctxUserUID(c *gin.Context) string {
v, ok := c.Get("userUID")
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// ctxBasePath returns the PhotoPrism user BasePath resolved by requireSession.
func ctxBasePath(c *gin.Context) string {
v, ok := c.Get("basePath")
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}

92
sidecar/config.go Normal file
View File

@@ -0,0 +1,92 @@
package main
import (
"os"
"path/filepath"
"strconv"
)
// Config aggregates every runtime knob the sidecar reads from the
// environment. Held in one struct so the rest of the package can take a
// pointer instead of poking os.Getenv at use-sites.
type Config struct {
OriginalsRoot string // absolute path to PhotoPrism's originals dir
PhotoprismBaseURL string // e.g. http://localhost:2342
ListenAddr string // bind interface — 127.0.0.1 for host mode, 0.0.0.0 in containers
Port int // HTTP listen port
DSN string // GORM/MySQL connection string for mule_sidecar
// PpDSN is a second DB connection string pointed at PhotoPrism's own
// schema (`photoprism.*`). Sidecar code that needs to mutate
// PhotoPrism-managed rows (e.g. auth_users.base_path) opens its own
// connection with these creds rather than asking for grants on the
// mule_sidecar user. Empty if PP_DB_PASSWORD isn't provided, in
// which case PP-touching features (user-basepath reconciler) stay
// dormant.
PpDSN string
// UserBasepaths is the parsed `USER_BASEPATHS` env. Maps PhotoPrism
// usernames to originals-relative base paths so OIDC-provisioned
// users land with the right library scope without any admin
// touching `photoprism users mod`.
UserBasepaths map[string]string
}
func loadConfig() (*Config, error) {
root := envOr("ORIGINALS_ROOT", "/photoprism/originals")
abs, err := filepath.Abs(root)
if err != nil {
return nil, err
}
portStr := envOr("SIDECAR_PORT", "8000")
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, err
}
dsn := os.Getenv("SIDECAR_DSN")
if dsn == "" {
// Default matches the user that mariadb/init/01-sidecar.sql provisions
// on first boot. The literal placeholder password is intentional: the
// SQL ships with it and an env-templating step is left for whoever
// runs this in a non-local context.
user := envOr("SIDECAR_DB_USER", "sidecar")
pass := envOr("SIDECAR_DB_PASSWORD", "replace-at-m4-bringup")
host := envOr("SIDECAR_DB_HOST", "127.0.0.1")
dbPort := envOr("SIDECAR_DB_PORT", "3306")
name := envOr("SIDECAR_DB_NAME", "mule_sidecar")
dsn = user + ":" + pass + "@tcp(" + host + ":" + dbPort + ")/" + name +
"?charset=utf8mb4&parseTime=true&loc=Local"
}
// PhotoPrism schema connection — only used by the user-basepath
// reconciler. Stays empty if PP_DB_PASSWORD isn't set, and callers
// gate behaviour on that. We use PhotoPrism's own DB user rather
// than the sidecar's because `mule_sidecar` has no grants on
// `photoprism.*` (see mariadb/init/01-sidecar.sql).
ppDSN := ""
if ppPass := os.Getenv("PP_DB_PASSWORD"); ppPass != "" {
ppUser := envOr("PP_DB_USER", "photoprism")
ppHost := envOr("PP_DB_HOST", envOr("SIDECAR_DB_HOST", "mariadb"))
ppPort := envOr("PP_DB_PORT", envOr("SIDECAR_DB_PORT", "3306"))
ppName := envOr("PP_DB_NAME", "photoprism")
ppDSN = ppUser + ":" + ppPass + "@tcp(" + ppHost + ":" + ppPort + ")/" + ppName +
"?charset=utf8mb4&parseTime=true&loc=Local"
}
return &Config{
OriginalsRoot: abs,
PhotoprismBaseURL: envOr("PHOTOPRISM_BASE_URL", "http://localhost:2342"),
ListenAddr: envOr("SIDECAR_LISTEN_ADDR", "127.0.0.1"),
Port: port,
DSN: dsn,
PpDSN: ppDSN,
UserBasepaths: parseUserBasepaths(os.Getenv("USER_BASEPATHS")),
}, nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

63
sidecar/db.go Normal file
View File

@@ -0,0 +1,63 @@
package main
import (
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Mark mirrors the per-photo extras the web client stores via the marks
// endpoints — rating + four-colour label. Composite primary key
// (photo_uid, user_name) so each user has independent marks. Both payload
// fields are nullable so the sparse "no rating / no colour" state
// round-trips cleanly.
type Mark struct {
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"`
UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"`
Rating *int `gorm:"column:rating" json:"rating,omitempty"`
Color *string `gorm:"size:16;column:color" json:"color,omitempty"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
// TableName pins the GORM-pluralised default to a name that matches the
// other tables the M4 plan calls out (`marks`, `heap_shares`, …) so
// nothing surprising lands in the schema.
func (Mark) TableName() string { return "marks" }
// asJSON returns the wire shape clients expect — same flat object the
// Node prototype emitted. An empty Mark (rating=nil, color=nil) renders
// as `{}` which the client treats as "no mark on this photo".
func (m *Mark) asJSON() map[string]any {
out := map[string]any{}
if m == nil {
return out
}
if m.Rating != nil {
out["rating"] = *m.Rating
}
if m.Color != nil && *m.Color != "" {
out["color"] = *m.Color
}
if !m.UpdatedAt.IsZero() {
// ISO-8601 with millisecond precision, UTC — matches the Node
// prototype's `new Date().toISOString()` so clients written against
// the old endpoint stay happy.
out["updatedAt"] = m.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z")
}
return out
}
func openDB(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return nil, err
}
if err := db.AutoMigrate(&Mark{}); err != nil {
return nil, err
}
return db, nil
}

213
sidecar/fs.go Normal file
View File

@@ -0,0 +1,213 @@
package main
import (
"crypto/sha1"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"strings"
)
// sanitizeFilename trims a user-supplied filename and rejects anything
// dangerous: path separators, leading dots, NUL bytes, the `.`/`..`
// pseudo-names, anything absurdly long. PhotoPrism's indexer is happy
// with most filename shapes; we lock down the ones a malicious or
// careless caller might use to escape the folder.
func sanitizeFilename(name string) (string, bool) {
s := strings.TrimSpace(name)
if s == "" || len(s) > 240 {
return "", false
}
if strings.HasPrefix(s, ".") {
return "", false
}
if s == "." || s == ".." {
return "", false
}
if strings.ContainsAny(s, "/\\\x00") {
return "", false
}
return s, true
}
// resolveUnderRoot takes a user-supplied relative path and returns its
// absolute form, but only when the resolved location lives under the
// configured originals root. Symlink escapes are caught by resolving the
// parent through filepath.EvalSymlinks first.
//
// `mustExist=false` is for the *target* of a rename/create where the
// terminal path isn't on disk yet; the parent still has to exist and
// still has to be inside the root.
func resolveUnderRoot(root, rel string, mustExist bool) (string, error) {
if rel == "" {
return "", errors.New("empty path")
}
clean := strings.TrimLeft(rel, "/")
if clean == "" || clean == "." {
return "", errors.New("empty path")
}
for _, seg := range strings.Split(clean, "/") {
if seg == "" || seg == ".." {
return "", errors.New("path traversal")
}
}
abs := filepath.Join(root, clean)
parent := filepath.Dir(abs)
parentReal, err := filepath.EvalSymlinks(parent)
if err != nil {
return "", err
}
if !sameOrUnder(parentReal, root) {
return "", errors.New("parent escapes originals root")
}
if mustExist {
if _, err := os.Stat(abs); err != nil {
return "", err
}
}
return abs, nil
}
// ensureWithinOriginals checks that an absolute path's parent resolves to
// somewhere inside the root after symlink evaluation. Used for the
// already-resolved-on-disk paths returned by PhotoPrism's Files[].
func ensureWithinOriginals(root, absPath string) bool {
real, err := filepath.EvalSymlinks(filepath.Dir(absPath))
if err != nil {
return false
}
return sameOrUnder(real, root)
}
func sameOrUnder(p, root string) bool {
if p == root {
return true
}
return strings.HasPrefix(p, root+string(os.PathSeparator))
}
// uniqueName resolves "destDir/basename" against collisions by appending
// `-1`, `-2`, … to the stem. Caps at 1000 attempts so a runaway loop
// can't pin the goroutine forever.
func uniqueName(destDir, basename string) (abs, name string, ok bool) {
ext := filepath.Ext(basename)
stem := strings.TrimSuffix(basename, ext)
for i := 0; i < 1000; i++ {
candidate := basename
if i > 0 {
candidate = stem + "-" + itoa(i) + ext
}
p := filepath.Join(destDir, candidate)
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
return p, candidate, true
}
}
return "", "", false
}
// itoa is the tiny stdlib-free formatter we use inside hot loops.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// fileEntry is the per-file row walkFiles emits. relPath stays root-
// relative so it can land in API responses unchanged.
type fileEntry struct {
RelPath string
AbsPath string
Size int64
}
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
// itself walks the same set; we keep the list in lock-step so callers
// don't see "duplicate" warnings about files PhotoPrism would ignore.
var supportedExts = map[string]struct{}{
".jpg": {}, ".jpeg": {}, ".png": {}, ".heic": {}, ".heif": {},
".tiff": {}, ".tif": {}, ".gif": {}, ".bmp": {}, ".webp": {}, ".avif": {},
".mov": {}, ".mp4": {}, ".m4v": {}, ".avi": {}, ".mkv": {}, ".webm": {},
".dng": {}, ".cr2": {}, ".cr3": {}, ".nef": {}, ".arw": {},
".orf": {}, ".rw2": {}, ".raw": {},
}
// walkFiles enumerates every supported media file under root, skipping
// dotfiles/dotdirs (matches PhotoPrism's indexer and our own quarantine
// folder). Errors on individual entries are swallowed so a single
// permission-denied dir doesn't abort the whole scan.
func walkFiles(root string) ([]fileEntry, error) {
var out []fileEntry
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil {
// Permission errors etc. — skip the offending subtree but
// keep walking. The dup-scan endpoint is best-effort.
if d != nil && d.IsDir() {
return filepath.SkipDir
}
return nil
}
name := d.Name()
if p != root && strings.HasPrefix(name, ".") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if _, ok := supportedExts[ext]; !ok {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
rel, err := filepath.Rel(root, p)
if err != nil {
return nil
}
out = append(out, fileEntry{
RelPath: rel,
AbsPath: p,
Size: info.Size(),
})
return nil
})
return out, err
}
// sha1File streams the file through a SHA1 hasher so a 4GB ProRes clip
// doesn't blow the process's RAM. Returns the hex digest.
func sha1File(absPath string) (string, error) {
f, err := os.Open(absPath)
if err != nil {
return "", err
}
defer f.Close()
h := sha1.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}

45
sidecar/go.mod Normal file
View File

@@ -0,0 +1,45 @@
module mule-sidecar
go 1.25.0
require (
github.com/gin-gonic/gin v1.12.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

101
sidecar/go.sum Normal file
View File

@@ -0,0 +1,101 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

230
sidecar/handlers_dups.go Normal file
View File

@@ -0,0 +1,230 @@
package main
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/gin-gonic/gin"
)
const quarantineDir = ".duplicates"
type dupFileLite struct {
Path string `json:"path"`
Size int64 `json:"size"`
}
type dupGroup struct {
Hash string `json:"hash"`
Size int64 `json:"size"`
IndexedPath *string `json:"indexedPath"`
Files []dupFileLite `json:"files"`
}
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
// looking up "which file path has this hash already indexed", used to
// hint the UI which copy to keep.
type dupListPhoto struct {
Files []ppFile `json:"Files"`
}
func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
start := time.Now()
slog.Info("dup.scan starting", "root", cfg.OriginalsRoot)
all, err := walkFiles(cfg.OriginalsRoot)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Group by size first: byte-identical files necessarily share size,
// so size-collision is a cheap O(N) prefilter that lets us skip
// hashing >95% of a typical library.
bySize := map[int64][]fileEntry{}
for _, f := range all {
bySize[f.Size] = append(bySize[f.Size], f)
}
// Hash size-collision buckets concurrently. Cap fan-out to GOMAXPROCS
// so we don't drown the disk with parallel reads on a spinning HDD.
type hashOut struct {
hash string
f fileEntry
}
var (
wg sync.WaitGroup
sem = make(chan struct{}, 4)
outMu sync.Mutex
byHash = map[string][]fileEntry{}
hashSize = map[string]int64{}
)
for size, group := range bySize {
if len(group) < 2 {
continue
}
for _, f := range group {
wg.Add(1)
sem <- struct{}{}
go func(f fileEntry, sz int64) {
defer wg.Done()
defer func() { <-sem }()
h, err := sha1File(f.AbsPath)
if err != nil {
return
}
outMu.Lock()
byHash[h] = append(byHash[h], f)
hashSize[h] = sz
outMu.Unlock()
}(f, size)
}
}
wg.Wait()
// Drop singletons (size collision but different hashes), then ask
// PhotoPrism which of the duplicates it has indexed so the UI can
// default the "keep" selection to that one.
groups := make([]dupGroup, 0)
for h, files := range byHash {
if len(files) < 2 {
continue
}
g := dupGroup{Hash: h, Size: hashSize[h]}
for _, f := range files {
g.Files = append(g.Files, dupFileLite{Path: f.RelPath, Size: f.Size})
}
// Best-effort lookup; swallow errors. The hash query is cheap on
// PhotoPrism's side (indexed column).
resp, err := pp.call(c.Request.Context(), http.MethodGet,
"/api/v1/photos?q=hash:"+h+"&count=1&merged=true", token, nil)
if err == nil && resp.OK {
var photos []dupListPhoto
if err := json.Unmarshal(resp.Body, &photos); err == nil && len(photos) > 0 {
if pf, ok := primaryFileOf(&ppPhoto{Files: photos[0].Files}); ok && pf.Name != "" {
p := pf.Name
g.IndexedPath = &p
}
}
}
groups = append(groups, g)
}
// Sort by reclaimable bytes descending (size × duplicate-count) so
// the biggest wins float to the top of the UI.
sort.Slice(groups, func(i, j int) bool {
return groups[i].Size*int64(len(groups[i].Files)-1) >
groups[j].Size*int64(len(groups[j].Files)-1)
})
ms := time.Since(start).Milliseconds()
slog.Info("dup.scan done", "groups", len(groups), "ms", ms)
c.JSON(http.StatusOK, gin.H{
"groups": groups,
"scannedMs": ms,
})
}
}
type dupArchiveBody struct {
Paths []string `json:"paths"`
}
type dupMoved struct {
From string `json:"from"`
To string `json:"to"`
}
type dupArchiveErr struct {
Path string `json:"path"`
Error string `json:"error"`
}
func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body dupArchiveBody
if err := c.ShouldBindJSON(&body); err != nil || len(body.Paths) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "paths[] required"})
return
}
// Each archive batch lands in its own timestamped subdir so the
// user can browse what was quarantined when (and recover by hand
// if they change their mind).
stamp := time.Now().UTC().Format("2006-01-02T15-04-05.000Z")
targetDir := filepath.Join(cfg.OriginalsRoot, quarantineDir, stamp)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// archiveOne moves a single file into the quarantine batch dir
// and returns the new relative path. Disambiguates same-basename
// collisions within the batch so two `IMG_0001.jpg` from
// different folders don't clobber each other.
archiveOne := func(rel string) (string, error) {
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
if err != nil {
return "", errors.New("invalid path")
}
base := filepath.Base(abs)
dest := filepath.Join(targetDir, base)
for i := 1; ; i++ {
if _, err := os.Stat(dest); errors.Is(err, os.ErrNotExist) {
break
} else if err != nil {
return "", err
}
stem := base[:len(base)-len(filepath.Ext(base))]
dest = filepath.Join(targetDir, stem+"__"+itoa(i)+filepath.Ext(base))
}
if err := os.Rename(abs, dest); err != nil {
// EXDEV fallback — copy+remove for libraries that span
// filesystems (e.g. originals on a different mount).
if err2 := copyFile(abs, dest); err2 != nil {
return "", err
}
if err2 := os.Remove(abs); err2 != nil {
return "", errors.New("moved but source remove failed: " + err2.Error())
}
}
relDest, _ := filepath.Rel(cfg.OriginalsRoot, dest)
return relDest, nil
}
moved := []dupMoved{}
errs := []dupArchiveErr{}
for _, rel := range body.Paths {
relDest, err := archiveOne(rel)
if err != nil {
errs = append(errs, dupArchiveErr{Path: rel, Error: err.Error()})
continue
}
moved = append(moved, dupMoved{From: rel, To: relDest})
slog.Info("dup.archive", "from", rel, "to", relDest)
}
// Reindex the entire library so PhotoPrism drops rows for the
// archived files. cleanup:true is critical — the files still
// exist on disk, just under .duplicates/ which the indexer
// ignores.
if len(moved) > 0 {
go func() {
if err := pp.reindex(context.Background(), token, "/"); err != nil {
slog.Warn("dup.archive reindex failed", "err", err)
}
}()
}
c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs})
}
}

307
sidecar/handlers_folders.go Normal file
View File

@@ -0,0 +1,307 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
"github.com/gin-gonic/gin"
)
// pathParam pulls the URL-encoded :rel out of the Gin context and
// unescapes it. UseRawPath is on at the router level (see main.go) so the
// raw value still carries `%2F` for nested paths; we decode here.
func pathParam(c *gin.Context, key string) (string, bool) {
raw := c.Param(key)
if raw == "" {
return "", false
}
dec, err := url.PathUnescape(raw)
if err != nil {
return "", false
}
return dec, true
}
type folderCreateBody struct {
Path string `json:"path"`
}
func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body folderCreateBody
if err := c.ShouldBindJSON(&body); err != nil || body.Path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
return
}
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.Path, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
if _, err := os.Stat(abs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
return
} else if !errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := os.Mkdir(abs, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rel, _ := filepath.Rel(cfg.OriginalsRoot, abs)
slog.Info("folder.create", "path", rel)
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
}
}
type folderRenameBody struct {
NewName string `json:"newName"`
}
func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
rel, ok := pathParam(c, "rel")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
var body folderRenameBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
newName, ok := sanitizeFilename(body.NewName)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain dirname"})
return
}
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
st, err := os.Stat(oldAbs)
if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
return
}
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
if _, err := os.Stat(newAbs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
return
} else if !errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
return
}
if err := os.Rename(oldAbs, newAbs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
slog.Info("folder.rename", "from", oldRel, "to", newRel)
go fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
c.JSON(http.StatusOK, gin.H{
"ok": true,
"oldPath": oldRel,
"newPath": newRel,
})
}
}
func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
rel, ok := pathParam(c, "rel")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
if abs == cfg.OriginalsRoot {
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
return
}
st, err := os.Stat(abs)
if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
return
}
entries, err := os.ReadDir(abs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(entries) > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "directory not empty"})
return
}
if err := os.Remove(abs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
slog.Info("folder.delete", "path", rel)
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
}
}
type folderCountsBody struct {
Paths []string `json:"paths"`
}
// folderCountsRow is the minimal PhotoPrism photo projection the handler
// needs — just UID, so dedupe-by-UID survives `merged=false` (which
// expands one photo into one row per File on disk). PhotoPrism returns a
// JSON array of much richer objects; unmarshalling into this small
// shape ignores everything we don't care about.
type folderCountsRow struct {
UID string `json:"UID"`
}
// handleFolderCounts returns photo counts for each PhotoPrism folder
// path in one round-trip. The web client used to fire one
// `/photos?count=1000` per folder from the browser (≈1 MB JSON per
// folder × N folders) to populate the left-sidebar tree. Moving the
// fan-out into the sidecar keeps the same correctness profile — same
// q-DSL, same `merged=false` UID dedupe — but the wire payload back
// to the browser collapses to a single small JSON object
// (`{path: count}`).
//
// We bounce off PhotoPrism with paginated `count=1000` calls and dedupe
// UIDs server-side rather than trusting a count header: PhotoPrism's
// `/photos` X-Count is the *per-page* row count (per existing front-end
// comment), not the total-match count, so we'd silently undercount any
// folder with more than 1000 files. The loop walks offsets until PP
// returns a short page, so the result is correct regardless of folder
// size (until the wider fan-out becomes the bottleneck, which is many
// orders of magnitude away on this hardware).
//
// Bounded concurrency caps the fan-out so a library with hundreds of
// folders doesn't open hundreds of connections to PhotoPrism at once.
// Errors per-folder degrade to count=0 rather than failing the whole
// batch — the sidebar would rather show a missing badge for one folder
// than nothing for any.
func handleFolderCounts(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body folderCountsBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
if len(body.Paths) == 0 {
c.JSON(http.StatusOK, gin.H{})
return
}
const maxInFlight = 8
var (
wg sync.WaitGroup
sem = make(chan struct{}, maxInFlight)
mu sync.Mutex
counts = make(map[string]int, len(body.Paths))
)
// Seed every input key so the response always carries the same
// shape the client posted, even for paths whose lookup failed.
for _, p := range body.Paths {
counts[p] = 0
}
for _, p := range body.Paths {
path := p
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
// `path:<x>` is an exact match in PhotoPrism's q-DSL —
// it matches only photos whose `photo_path` field equals
// <x>, not descendants. The indexer always nests photos
// under YYYY/MM, so an internal tree node like `2024` has
// zero direct children and reports a count of 0 unless we
// recurse. `path:<x>*` is the documented wildcard form and
// matches both `<x>` itself (no harm if empty) and every
// `<x>/...` descendant.
//
// `merged=false` still returns one row per File on disk,
// so HEIC + companion JPG count twice unless we dedupe by
// UID — which is what the old client-side code did, and
// what we keep doing here.
q := url.QueryEscape(`path:"` + path + `*"`)
const pageSize = 1000
seen := make(map[string]struct{})
for offset := 0; ; offset += pageSize {
resp, err := pp.call(c.Request.Context(), http.MethodGet,
fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=false&q=%s", pageSize, offset, q),
token, nil)
if err != nil || !resp.OK {
slog.Warn("folder.counts: pp call failed",
"path", path,
"offset", offset,
"err", err,
"status", func() int {
if resp != nil {
return resp.Status
}
return 0
}())
return
}
var rows []folderCountsRow
if err := json.Unmarshal(resp.Body, &rows); err != nil {
slog.Warn("folder.counts: parse failed", "path", path, "offset", offset, "err", err)
return
}
for _, r := range rows {
if r.UID == "" {
continue
}
seen[r.UID] = struct{}{}
}
if len(rows) < pageSize {
break
}
}
mu.Lock()
counts[path] = len(seen)
mu.Unlock()
}()
}
wg.Wait()
c.JSON(http.StatusOK, counts)
}
}
// fireReindex wraps pp.reindex with logging and a detached context so
// it can run in a goroutine after the response has gone out. The Node
// prototype kicks reindex with `void reindex(...)` and never awaits;
// matching that here keeps the apparent latency of mutating endpoints
// low (PhotoPrism's index can take seconds on a big folder).
func fireReindex(_ *Config, pp *ppClient, token, parentRel string) {
// pp.call's client already enforces a 60s timeout, so the parent
// context can be detached from the request — the handler has long
// since written its response.
if err := pp.reindex(context.Background(), token, parentRel); err != nil {
slog.Warn("reindex failed", "path", parentRel, "err", err)
}
}

View File

@@ -0,0 +1,70 @@
package main
import (
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// handleFoldersProxy proxies PhotoPrism's /api/v1/folders/originals and
// post-filters by the caller's BasePath so the folder tree only shows
// folders under the user's library root.
//
// Route: GET /api/sidecar/folders (behind requireSession)
func handleFoldersProxy(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
basePath := ctxBasePath(c)
// Forward query params to PhotoPrism.
query := c.Request.URL.RawQuery
if query == "" {
query = "recursive=true&uncached=true&files=false"
}
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/folders/originals?"+query, token, nil)
if err != nil || !resp.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream folders request failed"})
return
}
// Decode the response — PhotoPrism returns { folders: [...] }.
var payload struct {
Folders []map[string]any `json:"folders"`
}
if err := json.Unmarshal(resp.Body, &payload); err != nil {
c.Data(resp.Status, "application/json", resp.Body)
return
}
// If the user has no BasePath (admin/empty), return as-is.
if basePath == "" {
c.JSON(http.StatusOK, payload)
return
}
prefix := basePath + "/"
// Post-filter folders by Path field only — frontend handles BasePath
// prefix stripping via toUserPath().
filtered := make([]map[string]any, 0, len(payload.Folders))
for _, f := range payload.Folders {
rawPath, ok := f["Path"]
if !ok {
continue
}
pathStr, ok := rawPath.(string)
if !ok {
continue
}
// Keep only folders under the user's base path.
if pathStr == basePath || strings.HasPrefix(pathStr, prefix) {
filtered = append(filtered, f)
}
}
c.JSON(http.StatusOK, gin.H{"folders": filtered})
}
}

276
sidecar/handlers_heap.go Normal file
View File

@@ -0,0 +1,276 @@
package main
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
type heapConvertBody struct {
TargetFolder string `json:"targetFolder"`
Mode string `json:"mode"` // "move" or "copy"
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
DeleteHeap bool `json:"deleteHeap"`
}
type heapPhoto struct {
UID string `json:"UID"`
Files []ppFile `json:"Files"`
}
type heapErr struct {
UID string `json:"uid"`
Reason string `json:"reason"`
}
// copyFile is the os.Rename fallback for cross-device moves and the
// primary path for "copy" mode. Streams so a 4GB video doesn't pin
// memory; preserves mode bits, sets the modification time to now (we're
// creating a new inode either way).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
st, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(dst)
return err
}
return out.Close()
}
func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
albumUID := c.Param("uid")
var body heapConvertBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
mode := body.Mode
if mode != "copy" {
mode = "move"
}
deleteHeap := mode == "move" && body.DeleteHeap
var subfolder string
if body.Subfolder != "" {
s, ok := sanitizeFilename(body.Subfolder)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
return
}
subfolder = s
}
// Resolve destination under ORIGINALS_ROOT. Empty / "/" / "." mean
// "drop these into originals/ itself" (the modal's "Root" option).
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
// Pull the heap's photos via the q=album:UID query. count=1000 covers
// every realistic heap; merged=true expands stacked variants so we
// move the JPG/HEIC sibling alongside the primary.
q := url.QueryEscape("album:" + albumUID)
listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true"
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !resp.OK {
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
return
}
var photos []heapPhoto
if err := json.Unmarshal(resp.Body, &photos); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
return
}
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
heapDeleted := false
if deleteHeap {
r, err := pp.call(context.Background(), http.MethodDelete, "/api/v1/albums/"+albumUID, token, nil)
if err == nil && r.OK {
heapDeleted = true
} else if err != nil {
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: " + err.Error()})
} else {
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: HTTP " + itoa(r.Status)})
}
}
slog.Info("heap.convert",
"album", albumUID,
"mode", mode,
"moved", moved,
"copied", copied,
"errors", len(errs),
"heap_deleted", heapDeleted,
)
c.JSON(http.StatusOK, gin.H{
"moved": moved,
"copied": copied,
"errors": errs,
"heap_deleted": heapDeleted,
})
}
}
// movePhotoFiles moves (or copies) each photo's originals-rooted primary file
// into targetAbs — optionally into `subfolder` under it — then blocks on a
// PhotoPrism reindex of the destination plus every source parent so the next
// /photos fetch reflects the move. Shared by handleHeapConvert (album-scoped)
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently
// but move them identically. Returns per-photo errors in `errs`; the returned
// top-level error is only for a fatal precondition (subfolder mkdir failed).
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode string) (moved, copied int, errs []heapErr, err error) {
destAbs := targetAbs
if subfolder != "" {
destAbs = filepath.Join(targetAbs, subfolder)
if e := os.MkdirAll(destAbs, 0o755); e != nil {
return 0, 0, nil, e
}
}
sourceParents := map[string]struct{}{}
errs = []heapErr{}
for _, photo := range photos {
// Pick the file to physically move. PhotoPrism's "primary" file
// for a HEIC photo is the generated `.HEIC.jpg` preview that
// lives in storage/sidecar (Root=="sidecar"), not in originals
// — moving that path would fail "file missing on disk" every
// time. Prefer the primary that lives in originals (Root=="/")
// and fall back to the first originals-rooted file. PhotoPrism
// regenerates sidecars on reindex, so they don't need to follow.
var file ppFile
found := false
for _, f := range photo.Files {
if f.Root == "/" && f.Primary {
file, found = f, true
break
}
}
if !found {
for _, f := range photo.Files {
if f.Root == "/" {
file, found = f, true
break
}
}
}
if !found {
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
continue
}
srcRel := file.Name
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
continue
}
st, statErr := os.Stat(srcAbs)
if statErr != nil || !st.Mode().IsRegular() {
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
continue
}
if filepath.Dir(srcAbs) == destAbs {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
}
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
if !ok {
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
continue
}
dstAbs := filepath.Join(destAbs, name)
if mode == "move" {
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil {
// Cross-device renames fail with EXDEV — fall back to
// copy+remove so a library that spans filesystems still
// works.
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: mvErr.Error()})
continue
}
if err2 := os.Remove(srcAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
continue
}
}
moved++
} else {
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: cpErr.Error()})
continue
}
copied++
}
sourceParents[filepath.Dir(srcRel)] = struct{}{}
}
// Reindex the destination + every source parent so PhotoPrism's DB
// catches up. We block on these so the response only goes out after the
// index reflects the move — the frontend's invalidateQueries refetch
// needs the next /photos fetch to return the moved files, otherwise the
// folder view looks unchanged. PhotoPrism's index endpoint serialises
// calls internally; running them sequentially matches that contract.
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
paths := map[string]struct{}{destRel: {}}
for p := range sourceParents {
paths[p] = struct{}{}
}
if subfolder != "" {
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
paths[parent] = struct{}{}
}
for p := range paths {
reindex := "/"
if p != "" && p != "." {
reindex = "/" + p
}
fireReindex(cfg, pp, token, reindex)
}
return moved, copied, errs, nil
}
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."
// mean the Originals root itself) into a validated absolute path under the
// root. Shared by the heap-convert and photos-move destination handling.
func resolveMoveTarget(cfg *Config, targetFolder string) (string, error) {
trimmed := strings.Trim(targetFolder, "/")
if trimmed == "" || trimmed == "." {
return cfg.OriginalsRoot, nil
}
return resolveUnderRoot(cfg.OriginalsRoot, targetFolder, true)
}

171
sidecar/handlers_labels.go Normal file
View File

@@ -0,0 +1,171 @@
package main
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PpLabel mirrors the shape PhotoPrism's /api/v1/labels endpoint returns.
// We decode enough to filter + recalculate PhotoCount; fields the client
// doesn't render are skipped for token efficiency.
type PpLabel struct {
UID string `json:"UID"`
Name string `json:"Name"`
Slug string `json:"Slug"`
CustomSlug string `json:"CustomSlug"`
Priority int `json:"Priority"`
Favorite bool `json:"Favorite"`
PhotoCount int `json:"PhotoCount"`
Thumb string `json:"Thumb"`
CreatedAt string `json:"CreatedAt"`
UpdatedAt string `json:"UpdatedAt"`
}
// handleLabels proxies PhotoPrism's /api/v1/labels and then post-filters
// each label's PhotoCount (and removes labels with zero count) so they
// reflect only photos under the caller's BasePath.
//
// Route: GET /api/sidecar/labels (behind requireSession)
func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
basePath := ctxBasePath(c)
// Forward the query string (count, offset, q, all, …) to PhotoPrism.
query := c.Request.URL.RawQuery
// Call PhotoPrism's labels endpoint using the caller's token.
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/labels?"+query, token, nil)
if err != nil || !resp.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream labels request failed"})
return
}
// Decode labels.
var labels []PpLabel
if err := json.Unmarshal(resp.Body, &labels); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse labels"})
return
}
// If the user has no BasePath (admin/empty), return labels as-is.
if basePath == "" || ppDb == nil {
c.JSON(http.StatusOK, labels)
return
}
// One query: count + a representative scoped thumb for every label
// the user can see. Replaces N per-label queries with a single JOIN.
prefix := basePath + "/%"
type labelStat struct {
LabelUID string `gorm:"column:label_uid"`
Cnt int64 `gorm:"column:cnt"`
ThumbHash string `gorm:"column:thumb_hash"`
}
var stats []labelStat
if err := ppDb.Raw(`
SELECT lb.label_uid AS label_uid,
COUNT(DISTINCT p.id) AS cnt,
COALESCE(MIN(f.file_hash), '') AS thumb_hash
FROM photos_labels pl
JOIN photos p ON pl.photo_id = p.id
JOIN labels lb ON pl.label_id = lb.id
LEFT JOIN files f ON f.photo_uid = p.photo_uid
AND f.file_primary = 1
AND f.file_missing = 0
WHERE (p.photo_path = ? OR p.photo_path LIKE ?)
AND p.deleted_at IS NULL
GROUP BY lb.label_uid
HAVING cnt > 0
`, basePath, prefix).Scan(&stats).Error; err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "label stats query failed"})
return
}
cntMap := make(map[string]int64, len(stats))
thumbMap := make(map[string]string, len(stats))
for _, s := range stats {
cntMap[s.LabelUID] = s.Cnt
thumbMap[s.LabelUID] = s.ThumbHash
}
filtered := make([]PpLabel, 0, len(stats))
for _, l := range labels {
cnt, ok := cntMap[l.UID]
if !ok || cnt == 0 {
continue
}
l.PhotoCount = int(cnt)
if th := thumbMap[l.UID]; th != "" {
l.Thumb = th
}
filtered = append(filtered, l)
}
c.JSON(http.StatusOK, filtered)
}
}
// Now also handle the session/config count scoping.
// PpCounts mirrors PhotoPrism's session config.count block that drives
// the sidebar badges (review, archive, all, etc.).
type PpCounts struct {
All int `json:"all"`
Photos int `json:"photos"`
Media int `json:"media"`
Videos int `json:"videos"`
Review int `json:"review"`
Archived int `json:"archived"`
Hidden int `json:"hidden"`
Private int `json:"private"`
Favorites int `json:"favorites"`
}
// handleScopedCounts returns user-scoped counts for review/archive/all
// so the sidebar badges match what the user actually sees.
//
// Route: GET /api/sidecar/counts (behind requireSession)
func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
basePath := ctxBasePath(c)
if basePath == "" || ppDb == nil {
// Admin or no DB — can't scope, return empty.
c.JSON(http.StatusOK, PpCounts{})
return
}
prefix := basePath + "/%"
pathCond := "(p.photo_path = ? OR p.photo_path LIKE ?)"
args := []any{basePath, prefix}
var counts PpCounts
// All non-deleted photos in this user's scope.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND `+pathCond, args...).Scan(&counts.All)
// Photos needing review (quality < 3).
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_quality < 3 AND `+pathCond, args...).Scan(&counts.Review)
// Archived (soft-deleted) photos.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NOT NULL AND `+pathCond, args...).Scan(&counts.Archived)
// Private photos.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_private = 1 AND `+pathCond, args...).Scan(&counts.Private)
// Photos (type image).
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('image','raw','live','animated') AND `+pathCond, args...).Scan(&counts.Photos)
// Videos.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('video','hdr','burst','live') AND `+pathCond, args...).Scan(&counts.Videos)
// Favorites.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_favorite = 1 AND `+pathCond, args...).Scan(&counts.Favorites)
c.JSON(http.StatusOK, counts)
}
}

212
sidecar/handlers_marks.go Normal file
View File

@@ -0,0 +1,212 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// validColors is the color palette the web client offers (COLOR_SWATCHES in
// web/src/lib/utils/tagGroups.ts) — keep the two in sync. The empty string is
// the explicit "clear color" sentinel.
var validColors = map[string]struct{}{
"red": {},
"orange": {},
"yellow": {},
"green": {},
"teal": {},
"blue": {},
"purple": {},
"pink": {},
}
// markPatch is the request body for all three mutating mark endpoints.
// Pointers distinguish "field omitted" from "field set to zero" — a PUT
// with `{"rating": 0}` clears the rating, but a PUT with `{"color": "red"}`
// alone must NOT wipe an existing rating.
type markPatch struct {
Rating *int `json:"rating,omitempty"`
Color *string `json:"color,omitempty"`
}
func (p *markPatch) sanitize() error {
if p.Rating != nil {
r := *p.Rating
if r < 0 || r > 5 {
return errors.New("rating out of range")
}
}
if p.Color != nil {
c := strings.ToLower(strings.TrimSpace(*p.Color))
if c != "" {
if _, ok := validColors[c]; !ok {
return errors.New("invalid color")
}
}
*p.Color = c
}
return nil
}
// apply merges the patch onto an existing row (or a fresh zero-value
// Mark for an upsert). Returns true if anything in the row still has a
// non-empty value — false signals "delete the row" to the caller.
func (p *markPatch) apply(m *Mark) bool {
if p.Rating != nil {
if *p.Rating > 0 {
r := *p.Rating
m.Rating = &r
} else {
m.Rating = nil
}
}
if p.Color != nil {
if *p.Color != "" {
c := *p.Color
m.Color = &c
} else {
m.Color = nil
}
}
return m.Rating != nil || (m.Color != nil && *m.Color != "")
}
// allMarksJSON renders the current user's marks as the wire shape
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
// GET /photos/marks which the web client calls once on session start.
func allMarksJSON(db *gorm.DB, userName string) (map[string]map[string]any, error) {
var rows []Mark
if err := db.Where("user_name = ?", userName).Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]map[string]any, len(rows))
for i := range rows {
out[rows[i].PhotoUID] = rows[i].asJSON()
}
return out, nil
}
func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
marks, err := allMarksJSON(db, ctxUserName(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, marks)
}
}
func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("uid")
var m Mark
err := db.Where("photo_uid = ? AND user_name = ?", uid, ctxUserName(c)).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, gin.H{})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, m.asJSON())
}
}
// upsert applies the patch and writes back. Returns the resulting JSON
// shape (empty map if the row was deleted).
func upsert(db *gorm.DB, uid, userName string, patch *markPatch) (map[string]any, error) {
var m Mark
err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).First(&m).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
m.PhotoUID = uid
m.UserName = userName
keep := patch.apply(&m)
m.UpdatedAt = time.Now().UTC()
if !keep {
if err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).Delete(&Mark{}).Error; err != nil {
return nil, err
}
return map[string]any{}, nil
}
if err := db.Save(&m).Error; err != nil {
return nil, err
}
return m.asJSON(), nil
}
func handleMarkPut(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("uid")
var patch markPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid patch"})
return
}
if err := patch.sanitize(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := upsert(db, uid, ctxUserName(c), &patch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
}
type bulkBody struct {
IDs []string `json:"ids"`
Patch markPatch `json:"patch"`
}
func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body bulkBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if len(body.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids[] required"})
return
}
if err := body.Patch.sanitize(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userName := ctxUserName(c)
applied := make(map[string]map[string]any, len(body.IDs))
// Single transaction so a partial failure rolls back. The client
// expects atomic semantics for a bulk star/colour stamp.
err := db.Transaction(func(tx *gorm.DB) error {
for _, uid := range body.IDs {
if uid == "" {
continue
}
out, err := upsert(tx, uid, userName, &body.Patch)
if err != nil {
return err
}
applied[uid] = out
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"count": len(applied),
"marks": applied,
})
}
}

174
sidecar/handlers_move.go Normal file
View File

@@ -0,0 +1,174 @@
package main
import (
"encoding/json"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
type photosMoveBody struct {
UIDs []string `json:"uids"`
TargetFolder string `json:"targetFolder"`
Mode string `json:"mode"` // "move" or "copy"
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
}
// handlePhotosMove moves/copies an arbitrary list of photos (by UID) into a
// folder under originals/. Mirrors handleHeapConvert but resolves the photos
// from a UID list instead of an album query, then shares movePhotoFiles for
// the on-disk work + reindex. Backs the grid's "Move to folder" action.
func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body photosMoveBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
if len(body.UIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no uids"})
return
}
mode := body.Mode
if mode != "copy" {
mode = "move"
}
var subfolder string
if body.Subfolder != "" {
s, ok := sanitizeFilename(body.Subfolder)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
return
}
subfolder = s
}
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
// Resolve the photos via a single q=uid:a|b|c query. PhotoPrism's
// search treats `|` as OR within a filter value, so one round-trip
// covers the whole selection; merged=true pulls stacked variants so
// the JPG/HEIC sibling travels with its primary.
q := url.QueryEscape("uid:" + strings.Join(body.UIDs, "|"))
listURL := "/api/v1/photos?q=" + q + "&count=" + itoa(len(body.UIDs)) + "&merged=true"
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !resp.OK {
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
return
}
var photos []heapPhoto
if err := json.Unmarshal(resp.Body, &photos); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
return
}
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
slog.Info("photos.move",
"requested", len(body.UIDs),
"mode", mode,
"moved", moved,
"copied", copied,
"errors", len(errs),
)
c.JSON(http.StatusOK, gin.H{
"moved": moved,
"copied": copied,
"errors": errs,
})
}
}
type folderMoveBody struct {
// Originals-relative destination parent. ""/"/"/"." mean the root.
TargetParent string `json:"targetParent"`
}
// handleFolderMove reparents a folder: moves the directory (and everything in
// it) under a different parent, keeping its own name. Mirrors
// handleFolderRename but the destination is a parent folder rather than a new
// name. A whole-tree os.Rename preserves subfolder structure.
func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
rel, ok := pathParam(c, "rel")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
var body folderMoveBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
st, err := os.Stat(oldAbs)
if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
return
}
targetParentAbs, err := resolveMoveTarget(cfg, body.TargetParent)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
return
}
// Can't move a folder into itself or one of its own descendants.
if sameOrUnder(targetParentAbs, oldAbs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"})
return
}
newAbs := filepath.Join(targetParentAbs, filepath.Base(oldAbs))
if newAbs == oldAbs {
c.JSON(http.StatusBadRequest, gin.H{"error": "already in that folder"})
return
}
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
return
}
if _, err := os.Stat(newAbs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
return
}
if err := os.Rename(oldAbs, newAbs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
slog.Info("folder.move", "from", oldRel, "to", newRel)
// Reindex both the old and new parents so PhotoPrism drops the moved
// rows from the source view and picks them up under the destination.
fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
fireReindex(cfg, pp, token, "/"+filepath.Dir(newRel))
c.JSON(http.StatusOK, gin.H{
"ok": true,
"oldPath": oldRel,
"newPath": newRel,
})
}
}

141
sidecar/handlers_photos.go Normal file
View File

@@ -0,0 +1,141 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// handlePhotos proxies PhotoPrism's /api/v1/photos and then post-filters
// the response so only photos under the caller's BasePath are returned.
// This fixes the review/archive tab cross-user leak.
//
// Route: GET /api/sidecar/timeline (behind requireSession)
func handlePhotos(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
basePath := ctxBasePath(c)
// Forward the raw query string to PhotoPrism.
query := c.Request.URL.RawQuery
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos?"+query, token, nil)
if err != nil || !resp.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"})
return
}
// Decode as a generic array so we can inspect Path without
// committing to a rigid struct (PhotoPrism's photo response
// varies between list/detail/search endpoints).
var photos []map[string]any
if err := json.Unmarshal(resp.Body, &photos); err != nil {
// If it's not an array (e.g. error, single object), pass through.
c.Data(resp.Status, "application/json", resp.Body)
return
}
// If the user has no BasePath (admin/empty), return as-is.
if basePath == "" {
// Forward PhotoPrism's X-Count header for countPhotos().
if count := resp.Header.Get("X-Count"); count != "" {
c.Header("X-Count", count)
}
c.JSON(http.StatusOK, photos)
return
}
prefix := basePath + "/"
// Post-filter by FileName field (originals-relative path).
filtered := make([]map[string]any, 0, len(photos))
for _, ph := range photos {
rawPath, ok := ph["FileName"]
if !ok {
continue
}
pathStr, ok := rawPath.(string)
if !ok {
continue
}
// Match exact basePath or basePath/...
if pathStr == basePath || strings.HasPrefix(pathStr, prefix) {
filtered = append(filtered, ph)
}
}
// Forward X-Count header adjusted to the filtered count.
c.Header("X-Count", itoa(len(filtered)))
c.JSON(http.StatusOK, filtered)
}
}
// handleNotes pages PhotoPrism's photo list to completion and returns only
// photos carrying a non-empty Caption (mule-image's "Note"), scoped to the
// caller's BasePath. Paging server-side is what makes this correct: the
// client can't tell when the *BasePath-filtered* list is exhausted (a full
// upstream page can filter down to a short — or empty — slice), but here we
// can key the loop off the raw upstream page length.
//
// Route: GET /api/sidecar/notes (behind requireSession)
func handleNotes(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
basePath := ctxBasePath(c)
prefix := basePath + "/"
const pageSize = 1000
out := make([]map[string]any, 0, 64)
seen := make(map[string]struct{})
for offset := 0; ; offset += pageSize {
path := fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=true&order=newest", pageSize, offset)
resp, err := pp.call(c.Request.Context(), http.MethodGet, path, token, nil)
if err != nil || !resp.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"})
return
}
var photos []map[string]any
if err := json.Unmarshal(resp.Body, &photos); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected photos response"})
return
}
rawLen := len(photos)
for _, ph := range photos {
// BasePath scope — same rule as handlePhotos.
if basePath != "" {
pathStr, _ := ph["FileName"].(string)
if pathStr != basePath && !strings.HasPrefix(pathStr, prefix) {
continue
}
}
// Non-empty caption only.
caption, _ := ph["Caption"].(string)
if strings.TrimSpace(caption) == "" {
continue
}
// Dedupe by UID — `merged` can still repeat a photo at a page seam.
uid, _ := ph["UID"].(string)
if uid != "" {
if _, ok := seen[uid]; ok {
continue
}
seen[uid] = struct{}{}
}
out = append(out, ph)
}
// A short upstream page means PhotoPrism has no more rows.
if rawLen < pageSize {
break
}
}
c.JSON(http.StatusOK, out)
}
}

143
sidecar/handlers_rename.go Normal file
View File

@@ -0,0 +1,143 @@
package main
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// renameBody mirrors the Node prototype's wire contract — a single
// `newName` field carrying the bare basename (no slashes).
type renameBody struct {
NewName string `json:"newName"`
}
// ppPhoto is the partial PhotoPrism photo shape we need to find the
// primary file's on-disk location. Anything we don't read stays
// unspecified so version drift across PhotoPrism builds doesn't break
// JSON unmarshalling.
type ppPhoto struct {
Files []ppFile `json:"Files"`
}
type ppFile struct {
Name string `json:"Name"`
Root string `json:"Root"`
Primary bool `json:"Primary"`
}
func primaryFileOf(p *ppPhoto) (ppFile, bool) {
if p == nil {
return ppFile{}, false
}
for _, f := range p.Files {
if f.Primary {
return f, true
}
}
if len(p.Files) > 0 {
return p.Files[0], true
}
return ppFile{}, false
}
func handleRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
photoUID := c.Param("uid")
var body renameBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
newName, ok := sanitizeFilename(body.NewName)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain filename"})
return
}
// Fetch the photo so we can resolve Files[0].Root + Name into a
// concrete on-disk path. PhotoPrism has no "file by UID" endpoint
// in this build, so the single-photo lookup is the cheapest path.
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos/"+photoUID, token, nil)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !resp.OK {
c.JSON(resp.Status, gin.H{"error": "photo not found"})
return
}
var photo ppPhoto
if err := json.Unmarshal(resp.Body, &photo); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo"})
return
}
file, ok := primaryFileOf(&photo)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "no files on this photo"})
return
}
root := strings.TrimPrefix(file.Root, "/")
if root == "" || root == "/" {
root = ""
}
relPath := filepath.Join(root, file.Name)
oldAbs := filepath.Join(cfg.OriginalsRoot, relPath)
if !ensureWithinOriginals(cfg.OriginalsRoot, oldAbs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"})
return
}
st, err := os.Stat(oldAbs)
if err != nil || !st.Mode().IsRegular() {
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})
return
}
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
if !ensureWithinOriginals(cfg.OriginalsRoot, newAbs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "new path escapes originals root"})
return
}
if _, err := os.Stat(newAbs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "target filename already exists"})
return
} else if !errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
newRel := filepath.Join(root, newName)
slog.Info("rename", "from", relPath, "to", newRel)
if err := os.Rename(oldAbs, newAbs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Trigger reindex on the parent so PhotoPrism picks up the new
// filename and drops the orphan row for the old name. Best-effort.
reindexPath := "/"
if root != "" {
reindexPath = "/" + root
}
if err := pp.reindex(c.Request.Context(), token, reindexPath); err != nil {
slog.Warn("rename reindex failed", "err", err)
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"oldName": file.Name,
"newName": newName,
"oldRelPath": relPath,
"newRelPath": newRel,
})
}
}

153
sidecar/main.go Normal file
View File

@@ -0,0 +1,153 @@
// mule-sidecar — Go service for endpoints PhotoPrism does not expose.
//
// Ports the Node prototype (server.mjs) to the stack the merge plan calls
// out: Go + Gin + GORM + MariaDB. Same wire contract as the prototype so
// the SvelteKit web client doesn't need to change.
//
// Auth model is unchanged: the caller's X-Auth-Token is the only authority.
// requireSession validates it against PhotoPrism's /api/v1/photos before
// any destructive op runs.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
cfg, err := loadConfig()
if err != nil {
slog.Error("config", "err", err)
os.Exit(1)
}
db, err := openDB(cfg.DSN)
if err != nil {
slog.Error("db open", "err", err)
os.Exit(1)
}
pp := newPPClient(cfg.PhotoprismBaseURL)
// Apply any declared username→BasePath mapping to PhotoPrism's
// auth_users table. Runs immediately + every 60s thereafter so a
// user who logs in after the sidecar booted still gets their
// BasePath wired without an admin restart.
startUserBasepathReconciler(cfg)
// Open a second DB handle pointed at PhotoPrism's own schema for
// handlers that need to query auth_users, photos, labels, etc.
// May be nil if PpDSN is empty (no PP_DB_PASSWORD set).
var ppDb *gorm.DB
if cfg.PpDSN != "" {
if d, err := openDB(cfg.PpDSN); err == nil {
ppDb = d
} else {
slog.Warn("pp db open failed — scoped labels/counts unavailable", "err", err)
}
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// Keep `%2F` literal in path params so callers can pass URL-encoded
// nested folder paths (e.g. `foo%2Fbar`) without the router splitting
// them into separate segments. Handlers decode via url.PathUnescape.
r.UseRawPath = true
r.UnescapePathValues = false
r.Use(gin.Recovery())
// Health probe — unauthenticated so a process supervisor can call it
// without needing PhotoPrism to be reachable.
r.GET("/api/sidecar/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"ok": true,
"originalsRoot": cfg.OriginalsRoot,
})
})
// Every other endpoint runs behind the session gate. Mounting them
// under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp))
{
auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
// User-scoped proxies — require PpDSN connection.
if ppDb != nil {
auth.GET("/labels", handleLabels(pp, ppDb))
auth.GET("/counts", handleScopedCounts(ppDb))
}
// User-scoped photos — post-filters by BasePath so review/archive
// tabs only show photos the user owns.
auth.GET("/timeline", handlePhotos(pp))
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
// the /notes view isn't capped to the newest slice.
auth.GET("/notes", handleNotes(pp))
// User-scoped folders — post-filters the folder tree by BasePath
// so the sidebar shows only folders under the user's library root.
auth.GET("/folders", handleFoldersProxy(pp))
}
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
}
// Graceful shutdown so an in-flight duplicate scan or heap convert
// gets a chance to finish (or at least flush logs) on SIGTERM.
idleClosed := make(chan struct{})
go func() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
slog.Info("shutdown signal received")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
close(idleClosed)
}()
slog.Info("mule-sidecar listening",
"addr", "http://"+addr,
"originals", cfg.OriginalsRoot,
)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("listen", "err", err)
os.Exit(1)
}
<-idleClosed
}

158
sidecar/pp.go Normal file
View File

@@ -0,0 +1,158 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/url"
"time"
)
// ppClient is the thin HTTP wrapper around PhotoPrism's /api/v1. It is
// deliberately *not* shared across requests with caching: each handler
// forwards the caller's X-Auth-Token, so a single shared http.Client (we
// reuse the stdlib default) plus per-call header injection is all we need.
type ppClient struct {
base string
h *http.Client
}
func newPPClient(base string) *ppClient {
return &ppClient{
base: base,
h: &http.Client{Timeout: 60 * time.Second},
}
}
// ppResp is the trimmed projection of an HTTP response that callers
// actually consume. Status + raw body are exposed so handlers can mirror
// PhotoPrism's status code or parse the body themselves. Header is
// retained for callers that need `X-Count` / `X-Limit` / `X-Offset` on
// list endpoints — PhotoPrism exposes total-match counts there.
type ppResp struct {
OK bool
Status int
Body []byte
Header http.Header
}
// call issues an authenticated request against PhotoPrism. body is
// optional; pass nil for GET/DELETE. We don't JSON-decode here — callers
// know the shape they want and decode lazily.
func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body any) (*ppResp, error) {
u, err := url.Parse(c.base)
if err != nil {
return nil, err
}
rel, err := url.Parse(urlPath)
if err != nil {
return nil, err
}
full := u.ResolveReference(rel).String()
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, full, reader)
if err != nil {
return nil, err
}
req.Header.Set("X-Auth-Token", token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.h.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
buf, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return &ppResp{
OK: resp.StatusCode >= 200 && resp.StatusCode < 300,
Status: resp.StatusCode,
Body: buf,
Header: resp.Header,
}, nil
}
// ppSessionUser is the subset of PhotoPrism's session response we need.
type ppSessionUser struct {
UserUID string `json:"UID"`
UserName string `json:"Name"`
BasePath string `json:"BasePath"`
}
type ppSessionResponse struct {
User ppSessionUser `json:"user"`
}
// resolveSession validates the token AND returns the authenticated user.
// Returns nil when the token is invalid or the response can't be parsed.
func (c *ppClient) resolveSession(ctx context.Context, token string) *ppSessionUser {
if token == "" {
return nil
}
r, err := c.call(ctx, http.MethodGet, "/api/v1/session", token, nil)
if err != nil {
slog.Warn("resolveSession: call failed", "err", err)
return nil
}
if !r.OK {
slog.Warn("resolveSession: not OK", "status", r.Status, "body", string(r.Body[:min(len(r.Body), 200)]))
return nil
}
var resp ppSessionResponse
if err := json.Unmarshal(r.Body, &resp); err != nil {
slog.Warn("resolveSession: unmarshal failed", "err", err, "body", string(r.Body[:min(len(r.Body), 200)]))
return nil
}
if resp.User.UserName == "" {
slog.Warn("resolveSession: empty username", "body", string(r.Body[:min(len(r.Body), 200)]))
return nil
}
return &resp.User
}
// validateSession is the cheapest probe that the supplied token is live:
// list one photo. 401 → bad/expired token. We never read the payload.
func (c *ppClient) validateSession(ctx context.Context, token string) bool {
if token == "" {
return false
}
r, err := c.call(ctx, http.MethodGet, "/api/v1/photos?count=1", token, nil)
if err != nil {
return false
}
return r.OK
}
// reindex tells PhotoPrism to re-walk a single subpath of originals and
// reconcile its DB with the on-disk state. Callers fire this after any
// rename/create/delete so the timeline catches up. `cleanup: true` drops
// orphan rows (e.g. the row for the file's old name after a rename).
//
// Best-effort: errors are surfaced to the caller, who logs but does not
// abort — the file mutation has already happened on disk by the time
// reindex runs.
func (c *ppClient) reindex(ctx context.Context, token, parentRel string) error {
if parentRel == "" {
parentRel = "/"
}
_, err := c.call(ctx, http.MethodPost, "/api/v1/index", token, map[string]any{
"path": parentRel,
"rescan": false,
"cleanup": true,
})
return err
}

124
sidecar/users.go Normal file
View File

@@ -0,0 +1,124 @@
package main
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// PhotoPrism's open-source edition doesn't expose any way to map OIDC
// claims to a per-user BasePath — every newly-registered OIDC user
// lands with `BasePath = ""`, which means PhotoPrism's ACL filter
// shows them nothing (non-admin) or everything (admin). Neither is the
// per-user library scope a homelab admin typically wants.
//
// This reconciler reads a declarative `USER_BASEPATHS` env at sidecar
// startup, formatted as `username:originals-relative-path` pairs
// separated by commas (whitespace tolerated), e.g.:
//
// USER_BASEPATHS="test:test, alice:family/alice, bob:bob"
//
// For every entry the sidecar:
// 1. Ensures the originals subdirectory exists (so PhotoPrism's path:
// filter has somewhere to point — empty dirs are fine).
// 2. UPDATEs `photoprism.auth_users.base_path` for the matching user
// row if the current value differs. Idempotent: rows already
// matching are skipped, and missing users are no-ops (they'll
// materialise when they log in via OIDC; the periodic ticker
// catches them on the next pass).
//
// A 60-second ticker keeps the mapping in lockstep with new OIDC
// registrations without needing a webhook.
func parseUserBasepaths(raw string) map[string]string {
out := map[string]string{}
for _, p := range strings.Split(raw, ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
bits := strings.SplitN(p, ":", 2)
if len(bits) != 2 {
continue
}
u := strings.TrimSpace(bits[0])
// Strip any leading/trailing slash so the value lands in
// auth_users.base_path the same way PhotoPrism's own user-edit
// UI persists it (relative, no slashes).
path := strings.Trim(strings.TrimSpace(bits[1]), "/")
if u == "" || path == "" {
continue
}
out[u] = path
}
return out
}
func reconcileUserBasepaths(ppDSN, originalsRoot string, mapping map[string]string) error {
if len(mapping) == 0 {
return nil
}
if ppDSN == "" {
return fmt.Errorf("USER_BASEPATHS set but PP_DB_PASSWORD missing — can't reach photoprism schema")
}
db, err := gorm.Open(mysql.Open(ppDSN), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return fmt.Errorf("open pp db: %w", err)
}
if sqlDB, derr := db.DB(); derr == nil {
defer sqlDB.Close()
}
for username, path := range mapping {
target := filepath.Join(originalsRoot, path)
if err := os.MkdirAll(target, 0o775); err != nil {
slog.Warn("user-basepath: mkdir failed", "user", username, "path", target, "err", err)
// Continue — the DB update is still useful so PhotoPrism's
// ACL kicks in even if the directory is created later.
}
res := db.Exec(`UPDATE auth_users
SET base_path = ?
WHERE user_name = ?
AND COALESCE(base_path, '') <> ?
AND deleted_at IS NULL`,
path, username, path)
if res.Error != nil {
slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
continue
}
if res.RowsAffected > 0 {
slog.Info("user-basepath: set", "user", username, "path", path)
}
}
return nil
}
func startUserBasepathReconciler(cfg *Config) {
if len(cfg.UserBasepaths) == 0 {
return
}
slog.Info("user-basepath: starting reconciler", "entries", len(cfg.UserBasepaths))
apply := func() {
if err := reconcileUserBasepaths(cfg.PpDSN, cfg.OriginalsRoot, cfg.UserBasepaths); err != nil {
slog.Warn("user-basepath: reconciliation error", "err", err)
}
}
apply()
go func() {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for range t.C {
apply()
}
}()
}

23
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

42
web/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.15.3 create --template minimal --types ts --install npm web
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

17
web/components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"css": "src/app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

2674
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
web/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.8.0",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.2",
"vite": "^8.0.7"
},
"dependencies": {
"@tanstack/svelte-query": "^6.1.29",
"@tanstack/svelte-virtual": "^3.13.24",
"axios": "^1.16.1",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"lucide-svelte": "^1.0.1",
"maplibre-gl": "^5.24.0",
"mode-watcher": "^1.1.0",
"svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"vidstack": "^1.12.13"
}
}

103
web/src/app.css Normal file
View File

@@ -0,0 +1,103 @@
@import "tailwindcss";
/*
* shadcn-svelte design tokens. Mirrors the canonical "new-york" preset.
* Both light + dark are declared so mode-watcher can flip between them.
*/
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
@layer base {
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
/*
* Tailwind v4 dropped the built-in `cursor: pointer` on buttons; restore
* it so every <button>, <summary>, and role="button" gets the click
* affordance. Disabled controls get `not-allowed` so the cursor mirrors
* the visual opacity-50 state already on most buttons.
*/
button:not(:disabled),
[role='button']:not([aria-disabled='true']),
summary {
cursor: pointer;
}
button:disabled,
[role='button'][aria-disabled='true'] {
cursor: not-allowed;
}
}

13
web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

19
web/src/app.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<!-- SVG favicon adapts to light/dark via `prefers-color-scheme`
inside the file itself; PNG remains as a fallback for browsers
that don't support SVG icons. Apple touch icon stays PNG
since iOS home-screen icons can't be SVG. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,643 @@
import { toast } from 'svelte-sonner';
import { batchEdit } from '$lib/services/batch';
import { invalidatePhotos } from '$lib/services/bulk';
import {
addToHeap,
approvePhoto,
batchArchive,
batchDelete,
batchRestore,
removeFromHeap,
type PpAlbum
} from '$lib/services/photoprism';
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import { photoNameAndDir } from '$lib/types/photoprism';
import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte';
import { openMove } from '$lib/stores/moveDialog.svelte';
import {
clearBulkToFirst,
clearSelection,
focusAfter,
indexOf,
selectRange,
selection,
setAnchor,
setFocused,
toggle
} from '$lib/stores/selection.svelte';
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
import {
startBulk,
doneBulk,
removedBulk,
failBulk,
setDetail,
markRemoved,
clearRemoved
} from '$lib/stores/bulkAction.svelte';
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
/**
* Optional parameters the host passes via `use:gridKeyNav={...}`.
*
* - `scrollToIndex`: invoked when the action's own arrow-nav lands on a
* tile that's currently windowed-out of the DOM. The host expands its
* render window and scrolls the now-mounted shell into view.
* - `onArrow`: when provided, the action delegates ALL arrow keys to the
* host instead of computing moves itself. Required for grids with
* interleaved non-tile rows (e.g. month headers): linear +/-cols math
* skips wrong because the column count of header rows is 1 (full-span),
* not the tile column count. The host owns the visual-row map and
* handles the (row, col) translation. Mirrors mule-image's
* `useGridKeyNav` pattern.
*/
export type ArrowKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown';
export interface GridKeyNavParams {
scrollToIndex?: (i: number) => void;
onArrow?: (key: ArrowKey, extending: boolean) => void;
}
/**
* Svelte `action` for the timeline grid. Owns:
* - Arrow-key focus navigation (with shift-extend) inside the visible grid
* - Click + shift/ctrl click selection mutations
* - Window-level shortcuts mirroring mule-image's keyboard layer:
* x archive-toggle, u restore, s + (19) add to
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
* ⌘A selects all visible.
* Rating + color labels are mouse-driven via the metadata sidebar — no
* keyboard shortcuts.
*
* Archive / restore target a synthesized "cull target list" — in priority:
* 1. multi-selection set
* 2. focused tile
*/
export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
let scrollToIndex = params.scrollToIndex;
let onArrow = params.onArrow;
/** Cached column count for the visible grid. Read from CSS
* (`grid-template-columns` resolves to a space-separated list of px
* sizes), invalidated by a `ResizeObserver` on the grid host. This
* keeps the read DOM-cheap regardless of how many tiles are mounted —
* critical once windowing renders only a slice of the order. */
let cachedCols: number | null = null;
let gridEl: HTMLElement | null = null;
function findGrid(): HTMLElement | null {
// The `[role="group"][aria-label="Photos"]` or simply the first
// element whose computed grid-template-columns has >1 track. The
// timeline grid sits inside `node` (the action target = <main>).
if (gridEl && node.contains(gridEl)) return gridEl;
const candidate = node.querySelector<HTMLElement>('[data-photo-grid]');
if (candidate) {
gridEl = candidate;
return candidate;
}
// Fallback: the first descendant that's display: grid with ≥2 cols.
// Avoids a hard coupling on the data-attribute in case the host
// hasn't tagged it yet.
for (const el of node.querySelectorAll<HTMLElement>('*')) {
const cs = getComputedStyle(el);
if (cs.display === 'grid' && cs.gridTemplateColumns.split(' ').length > 1) {
gridEl = el;
return el;
}
}
return null;
}
function tilesPerRow(): number {
if (cachedCols !== null) return cachedCols;
const grid = findGrid();
if (!grid) return 1;
const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length;
cachedCols = Math.max(1, cols);
return cachedCols;
}
const ro = new ResizeObserver(() => {
// Container width changed → column count likely changed too.
// Cheaper to invalidate than to recompute; tilesPerRow recomputes
// on next access (which is per keystroke at most).
cachedCols = null;
});
ro.observe(node);
function focusedIndex(): number {
return indexOf(selection.focused);
}
/** Move the focus cursor by `delta` tiles. When the move is NOT a
* shift-extension, the anchor is bumped to the new focused tile so the
* next shift-click/arrow starts from the user's current cursor (the
* "starting photo") instead of a stale toggle/selectOnly anchor.
*
* Scroll-into-view tries the direct DOM lookup first (works pre-
* windowing AND post-windowing for tiles already in the visible
* window); if the tile isn't rendered (windowed out), defer to the
* host-provided `scrollToIndex` which expands the window. */
function moveFocus(delta: number, extending: boolean) {
if (selection.order.length === 0) return;
const cur = focusedIndex();
const next =
cur < 0
? delta > 0
? 0
: selection.order.length - 1
: Math.min(Math.max(0, cur + delta), selection.order.length - 1);
const nextUid = selection.order[next];
// Plain arrow nav collapses any prior multi-selection down to the
// cursor: one ringed tile at a time. Shift-extend keeps `ids`
// growing from the anchor (selectRange runs after this).
if (!extending) clearSelection();
setFocused(nextUid);
if (!extending) setAnchor(nextUid);
const tile = node.querySelector<HTMLElement>(`[data-uid="${nextUid}"]`);
if (tile) {
tile.scrollIntoView({ block: 'nearest', inline: 'nearest' });
} else {
scrollToIndex?.(next);
}
}
/** Synthesize a target list. Multi-selection wins, then focused. */
function cullTargets(): string[] {
if (selection.ids.size > 0) return Array.from(selection.ids);
if (selection.focused) return [selection.focused];
return [];
}
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
const ids = cullTargets();
if (ids.length === 0) {
const verb = direction === 'restore' ? 'restore' : 'archive';
toast.message(`Nothing to ${verb}`, {
description: 'Click a photo or select some first'
});
return;
}
let target: boolean;
if (direction === 'archive') target = true;
else if (direction === 'restore') target = false;
else {
const first = cachedPhoto(ids[0]);
target = !(first?.Archived ?? false);
}
const opLabel = target ? 'Archiving' : 'Restoring';
const doneLabel = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
const tid = toast.loading(`${opLabel} ${ids.length}`);
startBulk(`${opLabel}`, ids);
try {
if (target) await batchArchive(ids);
else await batchRestore(ids);
} catch (err) {
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Archive/restore failed', { id: tid });
return;
}
if (target) {
// Destructive removal: flash a red cross, then pull the tiles out of
// the grid immediately (markRemoved) rather than waiting on the slow
// server-reconcile refetch. clearRemoved once the refetch settles so
// the archived-filtered page replaces the optimistic hide.
removedBulk(doneLabel, ids);
focusAfter(ids);
clearSelection();
await delay(500);
markRemoved(ids);
invalidatePhotos(ids);
const settled = queryClient.invalidateQueries({ queryKey: ['photos'] });
void queryClient.invalidateQueries({ queryKey: ['marks'] });
void settled.then(() => clearRemoved(ids));
} else {
doneBulk(doneLabel, ids);
focusAfter(ids);
clearSelection();
invalidatePhotos(ids);
void queryClient.invalidateQueries({ queryKey: ['marks'] });
}
toast.success(doneLabel, { id: tid });
pushUndo(doneLabel, async () => {
if (target) await batchRestore(ids);
else await batchArchive(ids);
invalidatePhotos(ids);
});
}
/** Permanently delete cull targets — only callable from the archive
* section (X is rerouted away from archive-toggle there). PhotoPrism
* rejects deletion of un-archived photos with a 4xx, so the section
* gate doubles as a safety guard against accidental deletes from the
* main timeline. Confirm dialog is mandatory — no undo path exists. */
async function deleteCullTargets() {
const ids = cullTargets();
if (ids.length === 0) {
toast.message('Nothing to delete', {
description: 'Click a photo or select some first'
});
return;
}
const msg =
ids.length === 1
? 'Permanently delete this photo? This cannot be undone.'
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
if (!confirm(msg)) return;
const tid = toast.loading(`Deleting ${ids.length}`);
startBulk('Deleting…', ids);
try {
await batchDelete(ids);
} catch (err) {
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
return;
}
// Destructive removal — same red-cross flash then immediate hide as archive.
removedBulk(`Deleted ${ids.length}`, ids);
focusAfter(ids);
clearSelection();
await delay(500);
markRemoved(ids);
invalidatePhotos(ids);
const settled = queryClient.invalidateQueries({ queryKey: ['photos'] });
void queryClient.invalidateQueries({ queryKey: ['marks'] });
void settled.then(() => clearRemoved(ids));
toast.success(`Deleted ${ids.length}`, { id: tid });
}
/** Approve cull targets — clears them out of the review pile by
* bumping each photo's quality score above PhotoPrism's review
* threshold. The op is one-way (no /unapprove route), so we don't
* push an undo entry: a re-keyed S would just be a no-op on
* already-approved photos. */
async function approveCullTargets() {
const ids = cullTargets();
if (ids.length === 0) {
toast.message('Nothing to keep', {
description: 'Click a photo or select some first'
});
return;
}
const tid = toast.loading(`Keeping ${ids.length}`);
startBulk('Keeping…', ids);
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
onProgress: (_done, _total, completedId) => {
const p = cachedPhoto(completedId);
if (p) setDetail(p.FileName ?? completedId);
}
});
if (errors.length) {
failBulk(ids);
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
id: tid,
description: errors[0].message
});
} else {
doneBulk(`Kept ${ids.length}`, ids);
toast.success(`Kept ${ids.length}`, { id: tid });
}
focusAfter(ids);
clearSelection();
invalidatePhotos(ids);
}
// ── S chord (add-to-heap) ────────────────────────────────────────────
// Press S: arm a short timer. A digit 19 within the window adds the
// cull targets to the Nth heap in the heap list. Any other key cancels
// the chord without firing. On timeout, fall back to the currently-
// viewed heap (i.e. when section==='heap'); otherwise show a hint toast.
let sChordTimer: number | null = null;
const S_CHORD_MS = 500;
function clearSChord() {
if (sChordTimer !== null) {
window.clearTimeout(sChordTimer);
sChordTimer = null;
}
}
async function addCullTargetsToHeap(heap: PpAlbum) {
const ids = cullTargets();
if (ids.length === 0) {
toast.message('Nothing to add', {
description: 'Click a photo or select some first'
});
return;
}
const tid = toast.loading(`Adding ${ids.length}${heap.Title}`);
startBulk(`Adding to ${heap.Title}`, ids);
try {
const { added } = await addToHeap(heap.UID, ids);
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
void queryClient.invalidateQueries({ queryKey: ['photos'] });
if (added.length === 0) {
failBulk(ids);
toast.error(`Nothing added to ${heap.Title}`, {
id: tid,
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
});
return;
}
doneBulk(`Added ${added.length}${heap.Title}`, ids);
if (added.length < ids.length) {
toast.success(`Added ${added.length}/${ids.length}${heap.Title}`, {
id: tid,
description: 'The rest were already in this heap.'
});
} else {
toast.success(`Added ${added.length}${heap.Title}`, { id: tid });
}
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
await removeFromHeap(heap.UID, added);
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
void queryClient.invalidateQueries({ queryKey: ['photos'] });
});
} catch (err) {
failBulk(ids);
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid });
}
}
async function addCullTargetsToHeapByIndex(idx: number) {
const heaps = queryClient.getQueryData<PpAlbum[]>(['heaps']) ?? [];
if (idx < 1 || idx > heaps.length) {
toast.message(`No heap #${idx}`);
return;
}
await addCullTargetsToHeap(heaps[idx - 1]);
}
async function addCullTargetsToActiveHeap() {
if (filters.section !== 'heap' || !filters.heapUid) {
toast.message('Press S then 19 to pick a heap');
return;
}
const heaps = queryClient.getQueryData<PpAlbum[]>(['heaps']) ?? [];
const heap = heaps.find((h) => h.UID === filters.heapUid);
if (!heap) {
toast.message('Active heap not found');
return;
}
await addCullTargetsToHeap(heap);
}
async function onKey(e: KeyboardEvent) {
// Don't hijack typing inside form fields.
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
// Modal owns arrow / Escape / Space while it's open — it handles
// its own linear nav, close-on-Esc, and close-on-Space. Action
// keys (X/S/U/A/Z) still pass through because they target the
// shared selection store and work the same in either context.
if (view.previewOpen) {
if (
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'Escape' ||
e.key === ' ' ||
e.code === 'Space'
) {
return;
}
}
// S+digit chord. A digit 19 within the chord window consumes the key
// and fires add-to-heap-N. Any other key cancels the chord without
// firing the default active-heap action — the user switched intent —
// and falls through to normal handling for that key.
if (sChordTimer !== null) {
if (/^[1-9]$/.test(e.key)) {
e.preventDefault();
clearSChord();
void addCullTargetsToHeapByIndex(parseInt(e.key, 10));
return;
}
clearSChord();
}
const meta = e.metaKey || e.ctrlKey;
const shift = e.shiftKey;
// Space on a focused tile opens the full-screen preview modal.
// Matches the dblclick gesture so the user has both keyboard and
// mouse paths to the same surface. `e.code === 'Space'` covers
// layouts where `e.key` is the dead-key combining mark.
if ((e.key === ' ' || e.code === 'Space') && !meta && !shift) {
if (selection.focused) {
e.preventDefault();
openPreview();
return;
}
}
// ── Grid nav keys ────────────────────────────────────────────────────
switch (e.key) {
case 'ArrowLeft':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown':
e.preventDefault();
if (onArrow) {
// Host owns the visual-row map (needed for grids with
// interleaved headers). The host calls setFocused +
// scrollToIndex + selectRange-on-shift itself.
onArrow(e.key, shift);
} else {
const delta =
e.key === 'ArrowLeft'
? -1
: e.key === 'ArrowRight'
? 1
: e.key === 'ArrowUp'
? -tilesPerRow()
: tilesPerRow();
moveFocus(delta, shift);
if (shift && selection.focused) selectRange(selection.focused);
}
return;
case 'Escape':
// First Esc collapses a multi-selection back to single-focus
// on its first member — the user's "starting photo" stays
// visible instead of vanishing. Only when there's no bulk
// does Esc fully dismiss focus.
if (clearBulkToFirst()) return;
clearSelection();
setFocused(null);
return;
case 'Tab':
// Tab in the grid context = mule-image's left-sidebar toggle.
// Browsers reserve Tab for focus traversal — preventDefault
// here is fine because the grid owns this surface.
e.preventDefault();
toggleLeftSidebar();
return;
case 'i':
case 'I':
if (!meta && !shift) {
e.preventDefault();
toggleRightSidebar();
}
return;
case 'b':
case 'B':
if (!meta && !shift) {
e.preventDefault();
toggleLeftSidebar();
}
return;
case 'z':
case 'Z':
if (meta) {
e.preventDefault();
const entry = await popAndRun();
if (entry) toast.success(`Undone: ${entry.label}`);
else toast.message('Nothing to undo');
}
return;
case 'a':
case 'A':
if (meta) {
e.preventDefault();
for (const id of selection.order) selection.ids.add(id);
return;
}
if (shift) return;
// Bare `a` on the EXIF Stripped review tab fires the same
// "Accept date & Keep" flow as the bar button. Mirrors the
// bar's all-targets-have-a-suggestion gate so the shortcut
// can't silently approve photos without a date fix.
if (
filters.section === 'review' &&
new URL(window.location.href).searchParams.get('tab') === 'stripped_exif'
) {
const ids = cullTargets();
if (ids.length === 0) return;
for (const id of ids) {
const p = cachedPhoto(id);
if (!p) return;
const { fileName, path } = photoNameAndDir(p);
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
return;
}
}
e.preventDefault();
void acceptDateAndKeep(ids);
}
return;
case 'x':
case 'X':
if (meta || shift) return;
e.preventDefault();
// Archive section: X becomes permanent delete (Keep/Delete
// is the binary flow there, mirroring Review's Keep/Archive).
// Everywhere else X toggles archive on the cull targets.
if (filters.section === 'archive') {
void deleteCullTargets();
return;
}
void toggleArchive('toggle');
return;
case 'u':
case 'U':
if (meta || shift) return;
e.preventDefault();
void toggleArchive('restore');
return;
case 'm':
case 'M': {
if (meta || shift) return;
e.preventDefault();
// Move the cull targets to a folder — opens the shared
// move-to-folder dialog (same one the bar button and the
// heap/folder kebabs use).
const moveIds = cullTargets();
if (moveIds.length === 0) {
toast.message('Nothing to move', {
description: 'Click a photo or select some first'
});
return;
}
openMove({ kind: 'photos', uids: moveIds });
return;
}
case 's':
case 'S':
if (meta || shift) return;
e.preventDefault();
// Review section repurposes S as the Keep affordance —
// matches the BulkActionBar button and keeps the binary
// Keep/Archive flow on home-row keys (S/X). The heap chord
// is meaningless here anyway (review photos can't sensibly
// be filed before they're approved).
if (filters.section === 'review') {
void approveCullTargets();
return;
}
// Archive section: S = Keep = restore back to the timeline
// (inverse of Delete on X). Same rationale as review —
// heap-filing an archived photo isn't a flow that fits the
// section's intent.
if (filters.section === 'archive') {
void toggleArchive('restore');
return;
}
// Arm the chord. A digit 19 within S_CHORD_MS picks heap N;
// otherwise we fall back to the currently-viewed heap.
clearSChord();
sChordTimer = window.setTimeout(() => {
sChordTimer = null;
void addCullTargetsToActiveHeap();
}, S_CHORD_MS);
return;
}
}
function onClick(e: MouseEvent) {
const tile = (e.target as HTMLElement | null)?.closest<HTMLElement>('[data-tile]');
if (!tile) return;
const uid = tile.dataset.uid;
if (!uid) return;
// Modifier clicks are the only paths this document-level handler
// owns. Plain clicks bubble to the tile button's onclick, which
// reduces selection to just that tile.
if (e.shiftKey) {
e.preventDefault();
selectRange(uid);
setFocused(uid);
} else if (e.metaKey || e.ctrlKey) {
e.preventDefault();
toggle(uid);
setFocused(uid);
}
}
node.addEventListener('click', onClick);
// Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work
// immediately on page load regardless of which element holds focus.
// The filter inside `onKey` keeps form-field typing safe.
window.addEventListener('keydown', onKey);
return {
update(next: GridKeyNavParams = {}) {
scrollToIndex = next.scrollToIndex;
onArrow = next.onArrow;
},
destroy() {
clearSChord();
node.removeEventListener('click', onClick);
window.removeEventListener('keydown', onKey);
ro.disconnect();
}
};
}

View File

@@ -0,0 +1,79 @@
/**
* Fires `onHit` whenever the attached element scrolls near the bottom of
* its scroll container. Mirrors PhotoPrism's infinite-scroll trigger from
* `frontend/src/page/photos.vue`: an IntersectionObserver on a sentinel
* div with a rootMargin equal to ~4 viewport heights, so the next page is
* fetched well before the user actually reaches the end.
*
* Usage: attach to a sentinel <div /> placed at the bottom of the scroll
* area. The host gates calls via `enabled` (= `hasNextPage && !isFetching`).
*
* <div use:nearBottom={{ onHit: fetchNextPage, enabled: canFetch }} />
*/
export interface NearBottomParams {
onHit: () => void;
/** When false the observer ignores intersections (use for the
* hasNextPage + !isFetchingNextPage gate). */
enabled?: boolean;
/** Pre-load distance in pixels. PhotoPrism uses `innerHeight * 4`;
* we default to the same. Caller can pass a number for tests. */
preloadPx?: number;
/** Optional scroll root (defaults to the viewport). Pass the
* scrolling ancestor when the page itself doesn't scroll, which is
* our case — the timeline scrolls inside `<main>`. */
root?: Element | null;
}
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
let current: NearBottomParams = params;
let io: IntersectionObserver | null = null;
// IntersectionObserver only emits on state changes. With a 4-viewport
// preload zone, the sentinel typically stays continuously intersecting
// across a whole fetchNextPage cycle: enabled flips false (fetching),
// the IO callback runs but no-ops, enabled flips back true — and no new
// event is emitted because the intersection state never changed. We'd
// stall mid-pagination. Remember the last reported intersection so the
// next `enabled` rising edge can re-fire manually.
let lastIntersecting = false;
function buildObserver(p: NearBottomParams) {
io?.disconnect();
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
lastIntersecting = e.isIntersecting;
}
if (lastIntersecting && current.enabled) current.onHit();
},
{
root: p.root ?? null,
// Inflate the root's bottom edge so we trip well before
// the sentinel actually enters the viewport.
rootMargin: `0px 0px ${preload}px 0px`
}
);
io.observe(node);
}
buildObserver(current);
return {
update(next: NearBottomParams) {
const rootChanged = next.root !== current.root;
const preloadChanged = next.preloadPx !== current.preloadPx;
const enabledRose = !current.enabled && !!next.enabled;
current = next;
if (rootChanged || preloadChanged) {
buildObserver(current);
return;
}
// `enabled` rising while the sentinel is still in the preload
// zone — no IO event coming, so fire manually.
if (enabledRose && lastIntersecting) current.onHit();
},
destroy() {
io?.disconnect();
}
};
}

View File

@@ -0,0 +1,78 @@
/**
* Drag-to-resize Svelte action. Attaches pointerdown to the host element
* (a thin handle on the inner edge of a sidebar) and writes the new width
* back via the supplied setter. The pointer is captured so the drag keeps
* tracking when the cursor leaves the handle.
*
* edge: 'right' — handle on the right edge of the panel; drag right widens
* edge: 'left' — handle on the left edge of the panel; drag left widens
*
* Usage:
* <div use:resizable={{ edge: 'right', getWidth: () => view.leftSidebarWidth, setWidth: setLeftSidebarWidth }} />
*/
export interface ResizableParams {
edge: 'right' | 'left';
getWidth: () => number;
setWidth: (px: number) => void;
}
export function resizable(node: HTMLElement, initial: ResizableParams) {
let params = initial;
let pointerId = -1;
let startX = 0;
let startWidth = 0;
function onDown(e: PointerEvent) {
if (e.button !== 0) return;
pointerId = e.pointerId;
startX = e.clientX;
startWidth = params.getWidth();
node.setPointerCapture(pointerId);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
node.addEventListener('pointermove', onMove);
node.addEventListener('pointerup', onUp);
node.addEventListener('pointercancel', onUp);
}
function onMove(e: PointerEvent) {
if (e.pointerId !== pointerId) return;
const dx = e.clientX - startX;
const delta = params.edge === 'right' ? dx : -dx;
params.setWidth(startWidth + delta);
}
function onUp(e: PointerEvent) {
if (pointerId === -1) return;
try {
node.releasePointerCapture(pointerId);
} catch {
// Pointer may already be released; ignore.
}
pointerId = -1;
document.body.style.cursor = '';
document.body.style.userSelect = '';
node.removeEventListener('pointermove', onMove);
node.removeEventListener('pointerup', onUp);
node.removeEventListener('pointercancel', onUp);
}
function onDoubleClick() {
// Reset to the current default-ish midpoint. Callers can override by
// providing their own dblclick handler; we just stop pointer events
// from leaking up so the page underneath doesn't react.
}
node.addEventListener('pointerdown', onDown);
node.addEventListener('dblclick', onDoubleClick);
return {
update(next: ResizableParams) {
params = next;
},
destroy() {
node.removeEventListener('pointerdown', onDown);
node.removeEventListener('dblclick', onDoubleClick);
}
};
}

View File

@@ -0,0 +1,164 @@
/**
* Tracks the first/last visible tile indices inside the attached scroll
* container so the host can render only `[first - BUFFER, last + BUFFER]`
* and leave the rest unmounted.
*
* Implementation: rAF-throttled scroll listener that scans the shell
* elements (every photo renders a `[data-uid-shell]` div regardless of
* the windowing window) and reports the first/last shell whose bounding
* rect intersects the scroll root.
*
* Why not an IntersectionObserver? Two real-world breakages:
*
* 1. Late tile registration on remount. With cached photo data, the
* host's child shells mount in the same pass as the scroll root,
* racing the observer setup → registrations silently drop, observer
* sees nothing, the window never updates.
* 2. Observer dead zone on fast scroll. The observer fires only when a
* sample tile crosses the root boundary. If the user flicks the
* scroll faster than the host's buffer can extend the mounted set,
* every sample tile leaves the viewport before the next one is
* mounted, the host's `onChange` stops firing, and the timeline
* goes blank.
*
* Querying shells directly sidesteps both: shells are always mounted,
* the scan happens on every scroll tick, and the result is the true
* first/last regardless of how fast the user dragged.
*
* Use it like:
*
* <main use:visibleRange={{
* onChange: (f, l) => { range.first = f; range.last = l; }
* }}>
* {#each photos as p, i}
* <div data-uid-shell={p.UID}>
* {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
* <Tile {p} />
* {/if}
* </div>
* {/each}
* </main>
*
* The `register`/`unregister` handle is kept for backwards compatibility
* with the existing host but is now a no-op — shell-based scanning
* doesn't need per-tile enrolment.
*/
export interface VisibleRangeParams {
onChange: (first: number, last: number) => void;
/** Retained for backwards compatibility — shell-scan ignores it. */
sampleEvery?: number;
/** CSS margin string for legacy callers; shell-scan ignores it. */
rootMargin?: string;
}
export interface VisibleRangeHandle {
register(el: HTMLElement, index: number): void;
unregister(el: HTMLElement): void;
}
export function visibleRange(node: HTMLElement, params: VisibleRangeParams) {
let current = params;
let lastFirst = -1;
let lastLast = -1;
let rafId: number | null = null;
// Between frames the visible band can shift by at most ~one viewport
// of shells (any further and it's a programmatic jump, which falls
// back to a full sweep below). 300 covers a fast-flick on the densest
// thumbnail preset (XS) plus a buffer; tightening it further saves
// little and risks missing the new band after a quick scroll-wheel
// flick.
const SCAN_MARGIN = 300;
function scanFrom(
shells: NodeListOf<HTMLElement>,
rootRect: DOMRect,
start: number
): [number, number] {
let first = -1;
let last = -1;
for (let i = start; i < shells.length; i++) {
const r = shells[i].getBoundingClientRect();
if (r.bottom < rootRect.top) continue;
if (r.top > rootRect.bottom) break;
if (first === -1) first = i;
last = i;
}
return [first, last];
}
function compute() {
rafId = null;
const shells = node.querySelectorAll<HTMLElement>('[data-uid-shell]');
if (shells.length === 0) return;
const rootRect = node.getBoundingClientRect();
// Anchor the sweep around the previous result so a deep timeline
// doesn't pay `getBoundingClientRect()` × (every-shell-above-the-
// viewport) on every scroll tick. Previous loop scanned from 0
// each time → quadratic-feeling on long sessions with 1000+
// loaded photos.
const startHint = lastFirst >= 0 ? Math.max(0, lastFirst - SCAN_MARGIN) : 0;
let [first, last] = scanFrom(shells, rootRect, startHint);
// Bounded scan missed the band — user scrolled past the hint
// margin (programmatic jump, filter-reset reflow, etc.). Fall
// back to a single full sweep to re-anchor. Costs the same as
// the old behavior on this one frame, then bounded scans take
// over again.
if (first === -1 && startHint > 0) {
[first, last] = scanFrom(shells, rootRect, 0);
}
if (first === -1 || last === -1) return;
if (first === lastFirst && last === lastLast) return;
lastFirst = first;
lastLast = last;
current.onChange(first, last);
}
function schedule() {
if (rafId !== null) return;
rafId = requestAnimationFrame(compute);
}
// Initial measurement. Two rAFs because the first runs *during* the
// current frame's mount cycle — shells may not have computed layout
// yet, so `getBoundingClientRect` returns zeros. Bouncing once more
// lets the browser finish layout before we measure.
requestAnimationFrame(() => requestAnimationFrame(compute));
node.addEventListener('scroll', schedule, { passive: true });
// Resize / content changes (new pages loaded, sidebar toggled,
// thumbnail size flipped) also shift the visible band — recompute.
const ro = new ResizeObserver(schedule);
ro.observe(node);
// No-op handle preserved so host code (`tileRegister`) doesn't need
// to change shape. Shell-scan reads geometry directly; per-tile
// registration isn't needed.
const handle: VisibleRangeHandle = {
register() {},
unregister() {}
};
(node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle;
return {
update(next: VisibleRangeParams) {
current = next;
},
destroy() {
if (rafId !== null) cancelAnimationFrame(rafId);
node.removeEventListener('scroll', schedule);
ro.disconnect();
delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle })
.__visibleRange;
}
};
}
/** Read the handle the action stashed on the scroll-root node. Kept for
* callers that still want the (now-no-op) register/unregister surface;
* new callers can ignore this entirely. */
export function getVisibleRangeHandle(node: HTMLElement | undefined): VisibleRangeHandle | null {
if (!node) return null;
return (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange ?? null;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Some files were not shown because too many files have changed in this diff Show More