Skip to content

Frank/feat/photos perf index#3488

Open
karlitschek wants to merge 14 commits into
frank/feat/photo-actions-menufrom
frank/feat/photos-perf-index
Open

Frank/feat/photos perf index#3488
karlitschek wants to merge 14 commits into
frank/feat/photo-actions-menufrom
frank/feat/photos-perf-index

Conversation

@karlitschek
Copy link
Copy Markdown
Member

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.

karlitschek and others added 3 commits May 3, 2026 21:38
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>
@karlitschek
Copy link
Copy Markdown
Member Author

karlitschek commented May 3, 2026

@codecov
Copy link
Copy Markdown

codecov Bot commented May 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

karlitschek and others added 11 commits May 3, 2026 22:08
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant