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