Frank/feat/photos perf index#3488
Open
karlitschek wants to merge 14 commits into
Open
Conversation
The photos timeline currently re-derives itself from filecache via DAV
REPORT on every page load — searching the user's whole storage by
mimetype is the dominant cost for libraries past a few thousand files.
Memories app solves this with a precomputed per-user index; this
change brings the same approach to the official photos app.
What this lays down (the indexed data path; the client still reads via
DAV — switching it over is the next change so this PR can land
incrementally with no behaviour change for users on day one):
Backend
- New `oc_photos_index` table: (user_id, file_id) primary,
(user_id, taken_at, mtime) covering index for the timeline query.
`taken_at` is denormalised from the EXIF DateTimeOriginal already
computed by OriginalDateTimeMetadataProvider, falling back to mtime
at insert time so the timeline can sort by a single non-null column.
- PhotoIndexService owns upserts; reads the cached EXIF capture time
via IFilesMetadataManager. Indexing is best-effort — exceptions are
logged but never bubble into the file pipeline.
- PhotoIndexNodeListener wires NodeCreated/Written/Renamed/Deleted to
the service. Fanout uses IUserMountCache::getMountsForFileId so a
single upload to a group folder produces one row per recipient,
matching the per-user timeline query shape.
- PhotoIndexBackfillJob walks every user's primary folder once with a
one-hour wall budget, modelled on the AutomaticPlaceMapperJob next
door. Cursor-by-uid lets it resume across cron ticks. Per-user
`index.backfillDone.<uid>` flag flips once the user's tree is fully
walked — that flag also drives the "ready" bit in the API.
API
- GET /api/v1/index/status → `{ready, indexed, total}` for the current
user. `total` is the cached estimate of photos in the user's primary
storage (filecache count by mimetype) so the progress bar doesn't
oscillate when filecache rows are added during the scan.
- GET /api/v1/index/timeline?before=<unix>&limit=<n> → compact rows by
descending taken_at. Indexed read path; not wired into the client
yet (next PR).
Frontend — migration banner only for now
- New `indexStatus` Pinia store: polls the status endpoint every 5s
while ready=false and stops once ready=true. Treats a 404 as ready
so the banner is silent on instances that haven't applied the
migration yet (avoids a frontend-deploy / occ-upgrade race).
- New `IndexProgressBanner.vue`: NcNoteCard with NcProgressBar, shown
only while `inProgress`. Mounted into TimelineView under the header
so it sits above the grid without pushing it.
- Banner copy: "Speeding up your library — indexed N of M photos
(X%). The timeline will load faster once this finishes." — frames
the wait as progress rather than a fault.
Build + lint clean.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`vite` was pinned to exact 7.1.5 in 1ae4759 (Vue 3 migration). Since then `@nextcloud/vite-config@2.5.2` raised its peer to `^7.1.10`, so fresh `npm install` now fails with an ERESOLVE for the peer mismatch. Bumping to the carat range lets npm resolve to the latest patch (7.3.2 in the lockfile) without re-pinning. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the timeline view to consume `/api/v1/index/timeline` instead of the DAV REPORT search. The DAV fetcher stays as a fallback — indexed reads are an optimisation, not a correctness boundary. Backend - `PhotoIndexMapper::getEnrichedTimelineForUser` joins the index with filecache and the favorites store (`vcategory_to_object` ⨝ `vcategory`). Returns one row per file with everything the client needs to render a tile: path, name, etag, size, mtime, taken_at, permissions, favorite. EXIF / GPS / IFD0 / blurhash / dimensions come from a single batched `IFilesMetadataManager::getMetadataForFiles` call in the controller — same per-fileId cache the rest of the metadata pipeline uses. - The query is filtered to the user's primary storage so DAV source URLs map cleanly to `/files/<user>/...`. Group folders / external storage / shared mounts stay reachable via the legacy DAV fetcher fallback (next change can lift this once we store the user-relative DAV path at index time). - `estimateTotalForUser` no longer takes a redundant `$userId` parameter — IDE was flagging it as unused; the SQL filters by storage_id alone. Frontend - New `IndexedTimelineSearch.ts` hits the API and converts each row into a `@nextcloud/files` `File` object with the same attribute shape the legacy DAV path produces. Downstream consumers (FilesByMonthMixin, FileComponent, Slideshow, AlbumPicker) don't know which fetcher produced the file — the contract is the `File` object, not the transport. - Cursor-based pagination using `taken_at` instead of an offset: `firstResult === 0` resets the cursor, subsequent calls step back in time. - `FetchFilesMixin.fetchFiles` checks `indexStatusStore().ready` and routes to the indexed fetcher when the per-user backfill is done. Falls back to DAV on any error, and never uses the indexed path for `onThisDay` / `onlyFavorites` / `extraFilters` queries — those are filter shapes the indexed endpoint doesn't yet implement, so the dashboard widget and filter UI keep working unchanged. - `resetFetchFilesState` clears the indexed cursor alongside the fetched fileId list (mirrors how the DAV path resets `firstResult`). Lint clean. Local build blocked by sandbox permissions on node_modules but production build runs on the devel server. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Member
Author
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Two playful surfaces inspired by iOS Photos / Memories: Burst stacks - `utils/burstClustering.ts` is a pure function over the already- fetched files: chain-clusters consecutive photos within ~3s of each other (chained off the previous member's timestamp, not the leader's, so 20-shot sustained bursts stay one stack). minSize=2 so Live Photos / quick double-taps qualify. - `FilesByMonthMixin` now folds members into their leader after computing the per-month grouping. The folded list is what the grid renders — visually you see the stack's leader; the rest are reachable through the slideshow. - `store/bursts.ts` is a tiny Pinia store with the leader→members map. Kept separate from the (large) files store so reactivity doesn't have to walk the whole files map on every stack lookup. - `FileComponent.vue` reads its own stack via the store. When it's a leader: relax `contain: strict` so two CSS pseudo-cards can peek out behind the tile (no extra <img> loads), plus a count badge in the top-right with `font-variant-numeric: tabular-nums` so the digits don't jitter as the count changes. - `TimelineView.openViewer` checks the store on click. Stack leaders feed the slideshow with ONLY the stack members; flipping prev/next stays inside the burst instead of jumping out into the full timeline. Singletons keep the old "all photos in the grid" behaviour. Date scrubber - `components/DateScrubber.vue`: vertical track on the right edge of the timeline with year labels (decimated to ~12 max so they don't overlap on tall windows) and a draggable thumb. Drag → jump the grid in real time; tap the bare track jumps without the drag. Standard slider keyboard semantics (Arrow / Home / End) for keyboard users. - A floating month/year tooltip pops up while scrubbing so the user's eye doesn't have to dart between the thumb and the grid to know where they're landing. - Resting opacity is 0.4, fades to 1 on hover or while scrubbing — ambient affordance that doesn't compete with the photos. - Hidden on viewports shorter than 480px (no useful track to drag). - TimelineView holds a `scrubberTarget` data field that the scrubber writes via the `jump` event; FilesListViewer's `scrollToSection` prop already supports the rest of the plumbing. Lint clean. Local build blocked by sandbox/node_modules state but production build runs on the devel server. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The per-tile overflow menu now exposes the same two operations the sidebar's photos tab does — favorite/unfavorite and manage tags — without forcing the user into the sidebar first. Plus the menu now shows up in the folders view too, not just the timeline. PhotoActionsMenu.vue - New "Add to favorites" / "Remove from favorites" entry. Reads `file.attributes.favorite` to pick the label + icon (Star / StarOutline) and dispatches the existing `toggleFavoriteForFiles` store action — same DAV PROPPATCH path as the bulk-selection ActionFavorite, no new transport. - New "Manage tags…" entry opens a dialog with a checkbox list of all user-visible system tags. Each toggle assigns / unassigns optimistically (UI flips immediately, reverts on DAV failure with showError) so the user can flick through several without waiting on a Save button. A small input at the bottom creates a brand-new tag and assigns it in one go. - The `file` prop is now typed as a structurally-minimal `ActionMenuFile` interface instead of `PhotoFile`. Both the timeline's PhotoFile and the folder view's FoldersNode satisfy it; missing EXIF / favorite attributes degrade gracefully (View metadata still shows the filename, the favorite toggle still works because it goes via the store's PROPPATCH which only needs the fileid). PhotoTagService.ts (new) - Thin wrapper over the `systemtags` and `systemtags-relations` DAV trees: fetchAllTags, fetchTagsForFile, assignTagToFile, unassignTagFromFile, createTag. Modeled on the systemtags app's `services/api.ts` + `services/files.ts` — re-implemented here rather than imported because that module isn't a public entry point and pulling its build chain in would be a bigger change. - Idempotent on assign (swallows 409) / unassign (swallows 404) so retries from the optimistic UI don't surface as errors. - Filters fetchAllTags to userVisible+userAssignable so admin-only tags don't appear in the casual photo menu. FileLegacy.vue (folders view tile) - Wraps the existing <a class="file"> in a relatively-positioned div and mounts PhotoActionsMenu next to it. The folder view now shows the same menu the timeline does. Hover/focus reveal mirrors FileComponent's behaviour. - An `actionFile` adapter computes the ActionMenuFile shape from the FoldersNode (folder-listing nodes don't carry the favorite bit, so the toggle starts from "not favorited" and flips server-side regardless). - New `delete-requested` emit so FoldersView can wire the trash flow without us reaching into its folder-content cache. Lint clean. Production build runs on the devel server. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Production build of all the changes on this branch (backend index + indexed-timeline data path + migration banner + burst stacks + date scrubber + favorite & tag actions in the per-photo menu). No source changes — these are the regenerated chunked JS/CSS artefacts that ship to the browser. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vue 3 doesn't reliably hoist a mixin's setup-return values onto the
consuming component's `this`. FilesByMonthMixin was returning the
burst Pinia store from setup() and accessing it as `this.bursts` in
the `fileIdsByMonth` computed — that came back as undefined in some
build modes, throwing:
TypeError: undefined is not an object (evaluating 'this.bursts.setStacks')
runtime-core.esm-bundler.js:275
Pinia stores are singletons — calling the use* function from anywhere
returns the same instance. Switched FilesByMonthMixin (computed),
FileComponent (computed), and TimelineView.openViewer to call
`burstStore()` directly instead of routing through `this.bursts`.
The setup blocks that only existed to surface the store are removed.
Build refresh attached.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /photos and /videos sub-routes pass `mimesType: imageMimes` / `videoMimes` to FetchFilesMixin so the legacy DAV REPORT can scope the search to images-only / videos-only. The indexed-timeline endpoint ignored that option, so once the per-user backfill completed and the client switched to the indexed read path, both tabs started showing the full set of media. Plumbed a `kind` query param through end-to-end: - `PhotoIndexMapper::getEnrichedTimelineForUser` takes an optional `kind` (`'images' | 'videos' | null`) and adds an `is_video = 0/1` WHERE clause. The boolean is denormalised at index time, so this stays a pure index lookup with no mimetype string parsing. - `IndexController::timeline` accepts `?kind=` and only forwards the two recognised values; anything else falls through to "all media" so a typo doesn't silently zero-out the timeline. - `IndexedTimelineSearch.detectKind` maps the client's `mimesType` array to the kind param. `imageMimes` → 'images', `videoMimes` → 'videos', the default `allMimes` (or any custom set) → undefined, i.e. no filter. Set-based comparison so a future caller passing the same mimes in a different order still maps cleanly. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In FileLegacy.vue the existing `img { z-index: 10 }` rule sits in
front of PhotoActionsMenu's default `z-index: 3`. So clicks aimed at
the 3-dot button passed through and landed on the underlying `<a>`,
opening the viewer instead of the menu.
Bumped the menu's z-index to 11 only when rendered inside FileLegacy
(scoped via :deep(.photo-actions) on the wrap), leaving the timeline
tile's z-index ordering — where the image isn't z-indexed — untouched.
Build refresh attached.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous z-index fix used `&__actions :deep(.photo-actions)` — which compiles to `.file-legacy-wrap__actions .photo-actions`, a descendant selector. But those two classes resolve to the same element (PhotoActionsMenu's root has both: its own `photo-actions` plus the `file-legacy-wrap__actions` we passed via the class binding). A descendant selector against the same element matches nothing, so the rule didn't apply and the menu stayed at its default z-index 3, behind the image at z-index 10. Clicks went to the `<a>` and opened the viewer. Moved the `:deep(.photo-actions)` selector up to the `.file-legacy-wrap` level, where `.photo-actions` is genuinely a descendant. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Photo tiles now respond to hover with a tactile-but-quiet effect: - The image inside the tile gently magnifies (scale 1.04, clipped by the tile's overflow). All three preview layers — blurhash, small, large — share the same transform so they don't slide relative to each other during the magnify. - The tile itself lifts with a soft drop shadow (0 6px 18px / 14%). - 220ms ease-out so the response feels immediate without dragging. - `:not(.selected)` gating so selection's existing scale-down + primary-color ring stays the dominant visual when both apply. - `prefers-reduced-motion` users get just the shadow, no scale. Applied in both tile components — `FileComponent` (timeline, favorites, photos, videos, faces, tags, albums, shared albums, collections, dashboard) and `FileLegacy` (folder view) — so the effect is consistent across every photo grid in the app. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumped the hover magnify from 1.04 → 1.07 and the transition from 220ms → 360ms. Reads as a more deliberate "this tile is responding to you" beat — less of a flicker, more of a presentation. Both FileComponent and FileLegacy bumped together so the timeline and folder views stay in lockstep. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumped the hover transition from 360ms ease-out → 520ms with an ease-out-quint curve (cubic-bezier 0.22, 1, 0.36, 1). The curve responds quickly at the start and then settles gently into the final scale, reading as deliberate / cinematic rather than UI-snappy. Both FileComponent and FileLegacy bumped together. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The date scrubber's drag was being silently swallowed by browser default pointer behaviours: without `touch-action: none` on both the track and the thumb, mobile + macOS-trackpad gestures get classified as page scrolls before our pointermove handler ever sees them. Result: clicking the thumb did nothing visible, the timeline didn't move. Also reworked the press handlers: - Track pointerdown and thumb pointerdown now funnel into the same `startDrag` path. A press anywhere on the track is BOTH an immediate jump and the start of a drag — so press-and-drag works whether the user grabs the thumb or any blank stretch. - Dropped `setPointerCapture` — it can drop the capture under Safari quirks (the original failure mode). The document-level `pointermove` + `pointerup` listeners catch the events reliably regardless of whether the cursor leaves the thumb. - Listen for `pointercancel` too, so a system gesture interruption (e.g. notification slide-in on iOS) cleanly ends the drag instead of leaving `isDragging = true`. - `dragMonth` is pre-set on press so the thumb position doesn't snap to a stale `activeMonth` on the first frame. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backend
PhotoIndexMapper::getEnrichedTimelineForUser does a single SQL: photos_index ⨝ filecache ⨝ vcategory_to_object ⨝ vcategory — returns rows with path, etag, size, mtime, taken_at, permissions, favorite per file. EXIF/GPS/IFD0/blurhash/dimensions come from one batched IFilesMetadataManager::getMetadataForFiles call — same cache the metadata pipeline already uses.
Query is scoped to the user's primary storage so DAV source URLs map cleanly. Group folders / external storage stay on the DAV fallback for now.
Frontend
IndexedTimelineSearch.ts hits /api/v1/index/timeline and produces File objects with the same attribute shape the legacy DAV fetcher produces — downstream consumers (FilesByMonthMixin, FileComponent, Slideshow, album-add) don't change.
FetchFilesMixin now routes through the indexed path when indexStatus.ready is true. Falls back to DAV on any error. onThisDay / onlyFavorites / extraFilters queries always use DAV — those filter shapes aren't implemented in the indexed endpoint yet.
Caveats to watch when testing:
First load on a fresh install: ready=false → still uses DAV, banner visible. After occ background-job:execute OCA\Photos\Jobs\PhotoIndexBackfillJob (or waiting for cron), ready=true → switches to indexed.
Group-folder photos: indexed path filters them out by design. They'll reappear when the next PR adds per-user DAV-path mapping at index time.
Filter UI (date / place / tag): falls through to DAV unchanged.