Skip to content

Frank/feat/photos perf sw and denorm - #3497

Open
karlitschek wants to merge 28 commits into
masterfrom
frank/feat/photos-perf-sw-and-denorm
Open

Frank/feat/photos perf sw and denorm#3497
karlitschek wants to merge 28 commits into
masterfrom
frank/feat/photos-perf-sw-and-denorm

Conversation

@karlitschek

Copy link
Copy Markdown
Member

feat(slideshow): colour-themed chrome — slideshow background eases to the dominant colour of the current photo (extracted from the blurhash already on disk, dimmed 0.55, transitioned via a CSS variable). Apple Music / TV-app aesthetic.
feat: smart auto-albums (Screenshots / Bursts) — two heuristic-classified subsets of the timeline. src/utils/smartAlbums.ts is a pure function over PhotoFile; routes mount TimelineView with a smartAlbumKind prop so all the existing affordances come along.
feat: save burst as animation — src/utils/burstAnimator.ts encodes a stack into a looping WebM via Canvas + MediaRecorder, entirely client-side. Menu entry only renders for stack leaders.
feat: cinematic Memories recap — full-screen CinematicRecap.vue with Ken-Burns pan/zoom on every frame, 1.2s cross-dissolves, colour-themed chrome, and an auto-title overlay. Drives MemoriesView's trip and year-recap cards.
feat: emoji reactions on photos in shared albums — oc_photos_reactions table + ReactionsController toggle endpoint + ReactionBar.vue (six-emoji picker with optimistic updates). Mounted into the in-app slideshow when opened with albumId.
feat: activity feed for shared albums — oc_photos_activity append-only table + ActivityController + ActivityFeed.vue sidebar component. ReactionsController already logs reaction events; file add/remove hooks are a small follow-up.

karlitschek and others added 28 commits May 3, 2026 18:50
Move the photos app off Vue 2.7 onto the Vue 3.5 toolchain and
update the surrounding Nextcloud library line to its Vue 3
counterparts.

Toolchain
- vue 2.7 → 3.5; vue-router 3 → 4; vuex 3 → 4; pinia 2 → 3
- @nextcloud/vue 8 → 9; @nextcloud/dialogs 6 → 7;
  @nextcloud/upload 1 → 2.0.0-rc.0
- @nextcloud/vite-config 1 → 2.5; @vue/tsconfig 0.5 → 0.8;
  @vue/test-utils 1 → 2; eslint 10
- vuex-router-sync 5 → 6.0.0-rc.1
- Drop vue-template-compiler, vue2-leaflet, vue-virtual-grid

Entry points and core wiring
- main.ts / public.ts / sidebar.ts / dashboard.ts now use
  createApp + app.use(...) + app.mount() (sidebar uses createApp /
  app.unmount() per tab mount/destroy)
- router/index.ts: createRouter + createWebHistory; /maps redirects
  via beforeEnter return value
- store/index.ts: createStore; vuex-router-sync v6
- services/GridConfig.ts switched from a Vue 2 instance event-bus
  to a reactive() singleton; mixin reads it via computed
- All `import Vue from 'vue'`, Vue.set/Vue.delete/Vue.nextTick,
  Vue.use(...), and Vue.prototype assignments removed
- this.$set / this.$delete replaced with direct assignment / delete
- beforeDestroy / destroyed renamed to beforeUnmount / unmounted

Template syntax
- Vue 2 slot= / slot-scope= → Vue 3 v-slot / #name (74 conversions
  across 16 components)
- Vue 2 `filters: {}` option + `{{ x | y }}` pipes → methods
- .sync modifiers → v-model:propName
- :deep with space → :deep(...)
- .native modifier removed
- vue-router 4 dropped the `exact` prop
- inheritAttrs: false removed where parents now pass class/style
  (which Vue 3 routes through $attrs and would otherwise drop)
- TiledRows.vue: `<template functional>` rewritten as a regular
  defineComponent

Reactivity / private-field trap
- Vue 3's reactive Proxy is incompatible with classes that use ES
  private fields (#field). markRaw the AbortController,
  SemaphoreWithPriority, ResizeObserver, IntersectionObserver,
  Uploader and webdav client instances stored in data(); use
  shallowRef in useAbortController.

Library replacements
- LocationMap.vue: vue2-leaflet → @vue-leaflet/vue-leaflet
- FoldersView.vue: vue-virtual-grid replaced with new GridLayout
  components (GridLayout.vue / GridLayout.ts / GridRow.vue) +
  VirtualScrolling, adopted from the upstream artonge/migrate_to_vue3
  branch
- @nextcloud/upload@2 dropped <UploadPicker>; replaced by a small
  LocalUploadPicker.vue that wraps getUploader() / batchUpload()
- isMobile mixin → useIsMobile() composable
- @nextcloud/dialogs v7: DialogSeverity enum → string-literal API
- @nextcloud/event-bus v3 typed channels declared in event-bus.d.ts

Type system
- tsconfig: vueCompilerOptions.target 2.7 → 3.5
- New global.d.ts with OC / OCA ambient declarations
- New assets-modules.d.ts for *.svg / *.svg?raw / *.png / *.jpg
- vuex.d.ts: corrected store path (./store.ts → ./store/index.ts)
  so $store augmentation flows through component types
- vue-router 4: Route → RouteLocationNamedRaw
- @vue/tsconfig 0.8: ComponentPublicInstanceConstructor (Vue 2.7
  internal) → Component
- webdav 5: GetDirectoryContentsOptions → ...WithDetails

Lint and code-quality cleanup
- ESLint config switched to recommendedVue2 → recommended (Vue 3)
- Auto-fixed attribute hyphenation and event hyphenation
- Custom event / slot naming kept as kebab-case (project
  convention) via local override
- Added explicit `emits: []` declarations across 10 components
- Removed 5 unused template refs
- Boolean prop defaults audited (2 inverted to false; 2 kept on
  with eslint-disable + rationale)
- 2 cypress JSDoc inline-tag escapes
- preserve-caught-error fixes (added cause)

Build state
- npm run build:   ✓ 9.94s, full bundle produced
- eslint .:        0 errors, 0 warnings
- vitest run:      1/1 passing

Carried-over caveats (documented in source with TODO markers):
- OCA.Files.Sidebar tab registration is deprecated in NC 33; we
  cannot move to the new `@nextcloud/files@4` Sidebar API yet
  because @nextcloud/upload@2.0.0-rc.0 hard-pins
  @nextcloud/files@^3.10.2.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OCP\Files\DavUtil::getDavPermissions was changed in Nextcloud 34
to require a parent FileInfo as a second argument (used to
determine renamability). Our PropFindPlugin still called it with a
single argument, throwing an ArgumentCountError on every PROPFIND
of a photos node and surfacing in the UI as "Failed to fetch
collections list."

Pass the node's parent through, mirroring the pattern used in
server's apps/dav and apps/files.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sfade

Three UI improvements that build on the Vue 3 migration.

1. Consolidate on the justified-row grid algorithm
   FoldersView was the last surface still using the fixed-square
   GridLayout component (introduced during Phase 3 to replace
   vue-virtual-grid). Switch it to TiledLayout, which is the same
   row-justified algorithm the timeline and album content already
   use, and delete the now-unused GridLayout/ directory.

   Folders and files are mapped through a small wrapper that adds
   the `id` and `ratio` fields TiledLayout needs (ratio=1 for both
   since the folder-listing endpoint doesn't return per-photo
   dimensions); the inner FileLegacy / FolderComponent rely on
   `object-fit: cover` to fill whatever tile shape is assigned.

   FileLegacy and FolderComponent also lose the `item.injected.X`
   indirection inherited from vue-virtual-grid's wrapper shape and
   read the item directly, matching the upstream Vue 3 migration
   branch.

2. Blurhash → small → large crossfade in FileComponent
   The previous code toggled the canvas blurhash and the two img
   layers via v-if chains driven by `loadedSmall`/`loadedLarge`,
   producing a hard pop as each layer arrived. Keep all three in
   the DOM and stack them: blurhash z-index 1, small thumbnail z-2
   (200ms fade-in), large preview z-3 (250ms fade-in). Each upper
   layer covers the one beneath as it becomes opaque, so the
   visual is a smooth blur → pixelated → sharp progression.

   Bonus fix: beforeUnmount referenced this.$refs.srcLarge (typo)
   instead of imgLarge, leaving in-flight large-preview loads
   uncancelled when a tile scrolled out of view.

3. Tile density toggle (small / default / large)
   New view-density toggle in the timeline header — NcActions menu
   with three NcActionRadio options. The selection drives a
   tileBaseHeight computed (mobile 80/120/200, desktop 120/200/320)
   that's forwarded to FilesListViewer.baseHeight; TiledLayout's
   row-justification already adapts to any base height.

   Persisted via the existing apps/photos/api/v1/config/{key}
   endpoint:

   - frontend: new `gridDensity: 'small' | 'medium' | 'large'`
     field on the userConfig store (default 'medium').
   - backend: UserConfigService.DEFAULT_CONFIGS gets `gridDensity`
     so it's auto-hydrated into the page initial state alongside
     the other config keys; ApiController::setUserConfig validates
     the value is one of small | medium | large.

Build / lint / tests all green after these changes.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five user-visible additions on top of the Vue 3 migration. Each is
intentionally minimum-viable so they ship together; each has clear
"deferred to a follow-up" boundaries called out below.

1. Free-text search by filename
   - New PhotosFilter `nameFilter` that emits a DAV `<d:like>` query
     against `<d:displayname/>`. Special characters in the user's
     query are escaped (XML + SQL LIKE wildcards) before being
     embedded in the request body.
   - NcTextField in PhotosApp's `#search` slot, debounced 300ms.
     Typing pushes a single value into `selectedFilters.name`; the
     existing filtersQuery / extraFilters pipeline does the rest, so
     search composes cleanly with the existing date-range and place
     filters.
   - Deferred: content search ("beach", "sunset") — that requires
     Recognize-app integration and is out of scope here. The
     filename + EXIF-anchored search should already feel
     transformative.

2. Trip memories (/memories)
   - New `services/memories.ts` clusters loaded photos by capture-
     date gaps (>2 days starts a new trip; clusters of <8 photos
     are dropped to keep noise out). Cover photo is picked from
     near the trip's midpoint to avoid arrival/departure shots.
   - New MemoriesView with tile cards (cover + date range + count).
     Click opens the NC Viewer with the trip's photos as the
     gallery list.
   - Routed at /memories with a navigation entry next to "On this
     day". On first visit the view triggers a 500-photo fetch so
     the page isn't empty before the timeline has been scrolled.
   - Deferred: server-side trip detection (would scale to libraries
     of millions of photos), and place-anchored / "year ago today"
     memory cards.

3. Photos map (/map)
   - New MapView using @vue-leaflet's LMap + LMarker, plotting every
     loaded photo that has GPS metadata. Click a marker → opens NC
     Viewer with all geotagged photos as the gallery list.
   - `nc:metadata-photos-gps` is now registered in main.ts so the
     timeline endpoint returns the field; previously it was only
     registered in sidebar.ts.
   - Centred on the centroid of the first 200 photos so users land
     somewhere relevant rather than on null island.
   - Replaces the previous /maps route that redirected out to the
     external Maps app. The inline view works for everyone whether
     or not Maps is installed.
   - Deferred: leaflet.markercluster integration (real libraries
     above ~5000 geotagged photos will need it).

4. Photo slideshow
   - New Slideshow component, full-page Teleport, plays through a
     supplied list of photos with play/pause + prev/next + close
     and ESC/arrow/space keyboard shortcuts. Auto-advance is 4s.
   - Triggered from a "Slideshow" button in the TimelineView header
     (visible when no selection is active and at least one photo
     has been fetched). Plays through the timeline's currently
     loaded photos in capture order.
   - Deliberately self-contained — the NC Viewer app is in a
     separate codebase, so adding a slideshow mode there is
     out-of-scope. This stays a Photos-internal feature.

5. Long-press multi-select
   - FileComponent now starts a 500ms long-press timer on
     pointerdown; if the press is held that long, the selection
     toggles and the subsequent click is swallowed. Mouse and
     touch both go through pointer events, so the same code paths
     handle desktop right-click-equivalent and mobile tap-and-hold.
   - The existing checkbox-on-hover affordance still works on
     desktop; long-press is the addition for mobile users for whom
     the checkbox was a finicky tap target.

Build / lint / tests all green after these changes.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rations, animated counters, album hero, year recap, EXIF overlay

Eight micro / meso polish items, each at minimum-viable scope. Calls
out the deferred bits inline so reviewers know what's deliberately not
in scope.

Animated favourite star
- Replace `v-once` on FavoriteIcon (which silently masked any
  toggle from the bulk-action menu) with a Vue `<Transition>`. The
  enter animation is a 320ms scale-bounce keyframe with a cubic
  overshoot timing function; leave fades out in 180ms.
- Skipped: the "particle burst on first favourite of session" bit;
  that needs session-state plumbing and a one-shot sentinel.

Selection ripple
- The previous `.selected` ring was a hard `outline` glued to the
  tile edge. Replace with a soft 0.97-scale lift + a 3px primary-
  colour glow + a 6px-blur drop shadow, all transitioned in 160ms.
  Keeps the focus ring (different state) for keyboard interaction
  via `&:focus-within / &:has(:focus)` so focus and selection are
  visually distinct.

Skeleton shimmer
- Layer a translucent diagonal gradient sweep on top of the
  blurhash (or the empty primary-element-light background when no
  blurhash exists) until the small or large preview lands. Pure
  CSS — translating gradient — so it costs nothing JS-side.
  Honours `prefers-reduced-motion: reduce`.

Illustrated empty states
- New EmptyIllustration.vue with four hand-drawn-ish SVG
  compositions (memories / map / faces / timeline). Each uses the
  Photos accent colour via `var(--color-primary-element)` so it
  follows the user's theme, including dark mode.
- Wired into MemoriesView and MapView; FacesView / empty-timeline
  TBD when those surfaces get a polish pass.

Animated counters
- Tiny AnimatedNumber.vue that tween-displays a `value` prop using
  requestAnimationFrame with an ease-out cubic curve, slot-exposing
  the live `displayValue` so callers can wrap it in their own
  translation (e.g. `n('photos', '%n photo', '%n photos', value)`).
  Honours `prefers-reduced-motion: reduce` by snapping straight to
  the final value.
- Used in Memories trip cards + the year-recap feature card; can be
  dropped in elsewhere later without changes.

Album page magazine spread
- New AlbumHero.vue: the album cover photo as a full-bleed 280px-
  tall hero with the album title overlaid on a darkening gradient.
  Subtitle combines location (if any) with the photo count via
  `translatePlural`. Hooks a passive scroll listener to translate
  the cover background up to 60px slower than the page for a soft
  parallax effect.
- Skipped (called out in commit so they're easy to find): album
  cover picker UI and accent-colour extraction from the cover
  image. Both worthwhile follow-ups; both larger than this round.

Year-in-review recap
- New `buildYearRecap` in services/memories.ts: groups loaded
  photos by year, picks the most-recent year with at least 30
  photos, then curates a target-of-60 set by including all
  favourites first then sampling evenly across the calendar so
  December doesn't dominate. Cover is the curated-set midpoint
  (avoiding the "first or last photo of the year" trap).
- Surface as a hero feature card on MemoriesView (above the trip
  grid) with eyebrow / title / count metadata. Click opens the
  curated set in the existing in-app Slideshow component, so the
  flow is "click → autoplay slideshow → close to return" with no
  cross-app coordination needed.
- Skipped: text-card sections between groups, music, and "X years
  ago today" cards. They're separate algorithms.

EXIF overlay in the slideshow
- Press `i` in the slideshow to toggle a frosted-glass aside that
  surfaces camera (Make + Model from IFD0), aperture, focal
  length, exposure, and ISO. Renders nothing when no EXIF data is
  available so we don't show an empty panel.
- Required registering `nc:metadata-photos-exif` and
  `nc:metadata-photos-ifd0` in main.ts — previously these were
  only fetched by the file sidebar.

Build / lint / tests all green after these changes.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 3-dot overflow menu on every photo tile so the actions you
already have buried in selection-mode or the slideshow are reachable
from the tile itself, without first having to enter selection.

PhotoActionsMenu.vue (new)
- NcActions popup with 4 entries: View metadata, Add to album,
  Share, Delete (separated). Action button is positioned top-start
  on the tile with a translucent black backdrop so it stays legible
  on any photo.
- "View metadata" is owned by the menu — it opens an inline NcDialog
  with the same camera / exposure / focal-length / ISO / aperture
  lines the slideshow overlay shows. Empty-state copy when the photo
  carries no EXIF.
- The EXIF field-selection logic is duplicated (rather than extracted
  into a shared util) on purpose; it's small, and a third caller
  doesn't exist yet. Comment in the file flags this for refactor.
- Delete prompts confirmation through a second NcDialog before
  emitting; the action menu itself never deletes silently.
- Add-to-album and Share emit upward; the parent owns the heavy
  flows so we don't double-implement album-pick / sidebar-open.

FileComponent.vue
- Mounts PhotoActionsMenu next to the existing favourite + selection
  affordances. New `showActionsMenu` prop (default true) so picker
  contexts can opt out — the menu would only confuse when the user
  is choosing photos to add somewhere.
- The menu is opacity-0 by default and revealed on `:hover` /
  `:focus-within`, matching the existing checkbox affordance.
- Forwards request-add-to-album / request-share / request-delete
  upward without interpreting them.

TimelineView.vue
- Wires the three new request-* events. Add-to-album re-uses the
  existing AlbumPicker flow but stores the single file in
  `singleFileForAlbumPicker`; addSelectionToAlbum then targets that
  file alone instead of the bulk selection.
- Share opens NC's Files sidebar on the path (its sharing tab
  already does the heavy lifting; no need to re-implement).
- Delete dispatches `deleteFiles` after optimistically dropping the
  id from `fetchedFileIds` so the tile vanishes immediately; the
  store re-adds on failure.

PhotosPicker.vue
- Passes `:showActionsMenu="false"` so the picker context stays
  about picking, not managing.

Build / lint clean (1 → 0 warnings; the showActionsMenu prop is
default-true on purpose, eslint-disable scoped to that line).

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier wiring called `OCA.Files.Sidebar.open(path)`, but that
legacy API was removed in NC34 (the modern API lives behind
`getSidebar()` from `@nextcloud/files`). So the Share menu item was
silently a no-op.

The replacement `getSidebar().open(node, 'sharing')` would also fail
in the photos context: the Files Pinia store guards on `activeView`
+ `activeFolder` being set, and photos uses its own router so neither
ever gets populated.

Instead, navigate the user to the Files app on the photo with
`opendetails=true`. That lands them in a fully-loaded sharing-capable
context (link shares, share-with-user, email, expiry, password, etc.),
which is what "share dialog" actually means in NC.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
NC34 dev builds throw inside `FilesMetadataManager::getKnownMetadata()`
during DAV SEARCH backend setup (strict lazy-AppConfig validation:
"The loading of lazy AppConfig values have been triggered by app
'core'"). That makes every DAV SEARCH request 500, which surfaces in
the photos app as "search is broken".

Sidesteps the problem by serving search out of our own backend
against `oc_photos_index` rather than DAV.

Backend
- `PhotoIndexMapper::searchUserTimeline` joins photos_index ⨝
  filecache ⨝ favorites (existing) ⨝ systemtag_object_mapping ⨝
  systemtag (new). Filter is `(filecache.name LIKE %q% OR
  systemtag.name LIKE %q% OR taken_at IN dateRange)`.
- `IndexController::search` parses the query for date hints (year
  alone like "2023", or year-month like "May 2023" / "2023-05" /
  "5/2023") and passes them as a [start, end) range. Tight grammar
  on purpose so a generic word like "dog" doesn't accidentally
  match a date.
- Pulled the response shaper out of `timeline()` into `composeItem`
  so both endpoints return the same JSON.

Client
- New `getIndexedSearchPhotos(query, options)` in
  `IndexedTimelineSearch.ts`. Same response decoder as the timeline
  endpoint, separate cursor namespace so search and timeline don't
  fight each other.
- `FetchFilesMixin.fetchFiles` is now a three-way:
    1. searchQuery + index ready → `/api/v1/index/search`
    2. no search + index ready + no exotic filters → `/api/v1/index/timeline`
    3. anything else → DAV REPORT (legacy fallback)
  Either indexed path falls back to DAV silently on HTTP failure.
- `TimelineView.getContent` extracts `selectedFilters.name` and
  passes it as the `searchQuery` option (DAV `extraFilters` still
  carries the equivalent name-filter XML, so older instances on the
  DAV-only path keep working).

The legacy DAV `nameFilter` was also broadened in this branch to
match name OR tag OR date — same matching semantics as the new
indexed path. Search input label updated to "Search by name, tag
or date (e.g. 2023, May 2023)".

Build refresh attached.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tiles for browser-playable video formats (mp4 / webm / ogg) now
swap their still thumbnail for a muted, looping inline `<video>`
when the cursor has been on them for ~250ms. Same affordance
Memories and iOS / macOS Photos use — at a glance the timeline
animates with what's inside each video, no click required.

FileComponent (timeline + favorites + photos + videos + faces +
tags + albums + collections + dashboard tiles)
- New `<video>` layer at z-index 5 (above the still layers),
  `object-fit: cover` so motion crops the same way as the
  thumbnail, brief 240ms fade-in to soften the still→motion swap.
- `schedulePreview()` waits 250ms before mounting so a fast cursor
  sweep across the grid doesn't fire dozens of media loads.
  Mirrors `iOS Photos` behaviour.
- `cancelPreview()` pauses the video, clears the src, and
  reload()s so the browser releases the buffer instead of holding
  the (potentially big) video in memory after hover-out.
- `prefers-reduced-motion: reduce` opts out — those users keep
  the still thumbnail.
- Codec gating in `isPreviewableVideo` keeps HEIC / HEVC / ProRes /
  rare codecs on the still path. Those need a transcode pipeline
  that's a separate, larger change.
- Network or codec error → mark the file's preview as failed and
  don't retry on subsequent hovers (else we'd churn loads forever
  on broken files).
- Cleanup wired into `beforeUnmount`.

FileLegacy (folder view tile)
- Same affordance, simpler structure: `<video class="video-preview">`
  pinned absolutely above the existing `<img>` (img is z-index 10,
  preview is 11). Same delay / cleanup / reduced-motion / codec
  gating logic.
- Hover handlers moved to `.file-legacy-wrap` so the wrap (which
  already drives the hover-shadow) can also drive the video swap
  without us double-binding events.

Build refresh attached.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ideo

Modern iPhones export HEVC by default and Chrome / Firefox refuse
to play those inline. The hover-autoplay preview that landed in the
previous PR has to fall back to the still thumbnail for those, and
the photos viewer's prev/next breaks with a black square.

Brings up an HLS pipeline that quietly transcodes those clips on
the server so the hover preview (and any future inline player) can
stream a browser-friendly variant. Modeled on Memories' go-vod
sidecar but in-process, no extra dependencies — uses ffmpeg if it's
installed (admins already need it for the Files preview thumbnail
of videos).

DB
- New `oc_photos_transcodes` table: file_id PK, state machine
  (`pending` → `transcoding` → `ready` / `failed` / `unsupported`),
  `attempts` for backoff, (state, updated_at) index for the
  worker's pull query.
- `Version34000Date20260504000000` migration. Goes alongside the
  existing photos_index migration; both target NC34.

Service layer
- `PhotoTranscodeMapper` owns the state machine. `claimNextPending`
  is atomic (UPDATE WHERE state='pending') so two cron ticks in
  parallel don't ffmpeg the same file. `releaseStaleTranscoding`
  reaps rows from a worker that died mid-run (>30 min in the
  `transcoding` state).
- `PhotoTranscodeService` runs ffmpeg. Single quality preset for
  now: H.264 main profile, scale to 720p max, 2 Mbps video + 128
  kbps stereo AAC, 6-second segments. Independent_segments +
  program_date_time HLS flags so each segment is decodable
  standalone.
- Output goes under `<datadir>/photos_transcodes/<fileId>/` —
  segments are a cache, not user data, so we bypass IAppData and
  write directly to the local filesystem (admins on object-storage
  data dirs disable transcoding via the `enable_transcoding`
  AppValue).
- Authorisation guard: object storage / non-local files mark
  `unsupported` rather than queuing forever (no local path = no
  transcode).
- Reuses NC's existing `preview_ffmpeg_path` system config so
  admins don't have to set the binary twice; falls back to probing
  /usr/bin/ffmpeg, /usr/local/bin/ffmpeg, /opt/homebrew/bin/ffmpeg.

Worker
- `PhotoTranscodeJob` (TimedJob, 5-min interval, 30-min wall budget
  per tick). Loops `claimNextPending` → `transcodeFile` until the
  budget runs out or the queue empties.

Listener wiring
- `PhotoIndexNodeListener.onWritten` now also marks pending in the
  transcode mapper for any video with a non-browser-playable mime
  (mp4/webm/ogg are filtered out — those don't need transcoding).
- `onDeleted` deletes the transcode row + on-disk segments alongside
  the index row, so the cache doesn't outlive the source file.

API
- `GET /apps/photos/api/v1/transcode/{fileId}/master.m3u8` →
  the manifest. `application/vnd.apple.mpegurl`, 1h browser cache.
- `GET /apps/photos/api/v1/transcode/{fileId}/seg-{N}.ts` →
  individual segment. `video/mp2t`, 1d cache (segment names are
  content-addressed by N + manifest, so safe to cache long).
- 404 when the file isn't ready yet (or its codec wasn't
  supported); the client falls back to the still thumbnail / opens
  the original in the viewer.
- Authorisation gate: `userFolder->getById($fileId)` — if the user
  can't reach the file, the API returns 404 (no probing other
  users' libraries).

Out of scope for this PR — follow-ups
- Client wiring: the hover preview isn't yet pointing at the HLS
  manifest. That's a small, separate change that needs hls.js as
  a dependency for Chrome/Firefox (Safari plays HLS natively).
- Adaptive bitrate ladder: currently a single 720p variant. Once
  we have telemetry on devices that play badly, adding 480p / 1080p
  variants is straightforward (multiple ffmpeg outputs to a master
  manifest with #EXT-X-STREAM-INF entries).
- Hardware acceleration: NVENC / VA-API / VideoToolbox via ffmpeg's
  `-hwaccel` flag. Big speedup on machines that have a GPU.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Force NC's `app:upgrade` to pick up the new schema migrations and
background-job registrations from the perf-index / search-indexed /
hls-transcoding branches. The version bump is the trigger that
makes `occ app:enable photos` (and `occ upgrade`) re-run the
migration loader for this app.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an "Edit metadata…" entry to the per-photo overflow menu.
Lets the user correct the two EXIF fields users actually edit in
practice: capture date (cameras with the wrong clock; scanned old
photos) and GPS location (phones without GPS; corrections).

The edits don't touch the original file. They live in a new
photos-app table and overlay at read time, so:
- the original on disk stays byte-identical
- each user's overrides are private (bob's edit on a shared photo
  doesn't change what alice sees)
- a Reset button per field falls back to the EXIF / mtime value

Backend
- New `oc_photos_metadata_edits` table: composite PK
  `(user_id, file_id)`, NULLable `taken_at` / `gps_lat` / `gps_lng`.
  Lat/lng stored as DECIMAL(9,6) — six decimals = ~11 cm at the
  equator, plenty for hand-corrected locations, no float-precision
  drift. Index on `file_id` alone for the future delete listener.
- `PhotoMetadataEditMapper` upsert + per-file lookup + bulk lookup
  for a page of fileIds. Direct DB access (composite PK is
  incompatible with QBMapper).
- `MetadataEditController` exposes:
    GET  /api/v1/metadata/{fileId} → current overrides
    PUT  /api/v1/metadata/{fileId} → patch (omitted = unchanged,
                                     null = clear).
  Authorisation via `userFolder->getById($fileId)` so users can't
  probe other users' libraries.
- `IndexController.composeItem` now layers the override on top of
  the EXIF view: edited `taken_at` becomes both the response's
  top-level `takenAt` (timeline sort key) AND the metadata bag's
  `photos-original_date_time` (display key), so the photos UI
  stays internally consistent. Edited GPS replaces the EXIF GPS
  array entirely.
- On save the controller also pushes the resolved `taken_at` into
  `oc_photos_index.taken_at` for that user's row, so the timeline
  ORDER BY picks up the edit immediately without a reload.

Frontend
- `PhotoMetadataEditService.ts` thin REST wrapper. Patch shape:
  omitted field = leave alone, `null` field = clear override.
- `MetadataEditDialog.vue` form-based dialog with a
  `<input type="datetime-local">` for date and two NcTextField
  inputs for lat/lng. Live validation (range checks fire as the
  user types; Save stays disabled while values are out of range).
  Per-field "Reset to original" buttons that clear the stored
  override and re-seed from the EXIF defaults the caller passed.
- `PhotoActionsMenu` gets a new "Edit metadata…" entry next to
  "View metadata", with the same icon-on-the-left layout as the
  other actions. Mounted lazily so the API call only fires when
  the user actually opens it.

Out of scope (follow-ups)
- Map picker for GPS (latitude/longitude inputs are functional but
  not a great UX for non-technical users). Drop-in component
  candidates: `vue-leaflet` (already a dep on the map view) wired
  to a draggable marker.
- Title / description / orientation fields. Those are XMP, not
  EXIF, and need a different storage path because NC indexes them
  separately.
- Bulk edit (apply the same date offset to a multi-selection,
  useful for scanned albums where every photo is wrong by the
  same amount).
- Writing the overrides back to the file's actual EXIF (would
  require shelling out to exiftool / ffmpeg). Current overlay
  approach is non-destructive — that's a feature for now, but
  power users may eventually want both.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first scroll over a fresh library is the slowest user-facing
moment in photos: every visible tile fires `/api/v1/preview/{fileId}`
cold, NC re-encodes the thumbnail on demand at 64×64 and 1024×1024,
and a few dozen parallel ~200-500ms preview generations stutter the
load. After that the NC preview cache kicks in and everything is
fast.

Drains that cost into a background job so the user never sees it.

Schema
- New `oc_photos_preview_warmup` table — file_id PK, state
  (pending → warming → warmed / failed), attempts, updated_at.
  PK is per-file (not per-user) because NC's preview cache is
  global; warming once covers every user that mounts the file.

Service
- `PhotoPreviewWarmupMapper` mirrors `PhotoTranscodeMapper` but
  hands back batches of fileIds rather than one at a time —
  warming is sub-second per file so per-file claim round trips
  would dominate the wall budget.
- `PhotoPreviewWarmupService::warmFile` calls
  `IPreview::getPreview` at exactly the two sizes FileComponent
  asks for (64 and 1024). Idempotent: re-warming an already-warm
  file is a stat-and-return.
- `PREVIEW_SIZES` is the contract — if photos changes the layer
  sizes in FileComponent, this constant has to follow or the
  warmup misses.
- Disabled via `enable_preview_warmup` AppValue (default `true` —
  the perf win is the whole point). Admins on tight quotas
  (warming a 100k library adds ~3-5 GB to NC's appdata preview
  cache) leave it off.

Worker
- `PhotoPreviewWarmupJob` (TimedJob, 5-min interval, 5-min wall
  budget). Per tick: reap stale `warming` rows from a dead worker
  → backfill new pending rows from `oc_photos_index` (one-time
  bootstrap; the listener takes over for new uploads) → drain
  the queue in batches of 50.

Listener
- `PhotoIndexNodeListener.onWritten` now also marks the file
  pending warmup (idempotent — `markPending` is a no-op for
  already-pending / warmed files).
- `onDeleted` drops the warmup row alongside the index + transcode
  cleanup.

Bumped app version to 7.0.0-dev.2 so `occ upgrade` re-runs the
migration loader and picks the new table + job up.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related performance changes that show up most when navigating
between Photos / Videos / Favorites tabs and on big libraries.

Service-worker runtime cache (vite.config.ts)
- The SW was already registered with one CacheFirst rule for the
  preview endpoint. Added strategies for the indexed-read paths
  the perf-index branch introduced:
    /api/v1/index/timeline → StaleWhileRevalidate (200 entries,
       6h TTL). Returns the previous response instantly for tab
       transitions while the SW kicks off a fresh fetch in the
       background — feels "instant" on repeat visits.
    /api/v1/index/search → NetworkFirst with 4s timeout fall-back
       (50 entries, 30s TTL). Search wants freshness; the cache is
       only there to keep the previous results visible while the
       new query lands.
    /api/v1/index/status → NetworkFirst with 3s timeout fall-back
       (4 entries, 60s TTL). Migration banner needs near-real-time
       updates while the backfill runs.
    /api/v1/transcode/{id}/seg-N.ts → CacheFirst (2000 entries,
       30d TTL). Segments are content-addressed so safe to cache
       aggressively.
- Existing preview cache rule generalised to also match
  `publicPreview` (was missing on shared-album views).

Denormalised image dimensions on `oc_photos_index`
- New migration adds `width` / `height` (nullable INT UNSIGNED).
- `PhotoIndexService` now reads `photos-size` via
  `IFilesMetadataManager` at index time and writes both columns
  alongside the existing `taken_at`. NULL until metadata is ready
  — the metadata pipeline is async, so a freshly-written file may
  hit the listener before its size is computed; a subsequent
  NodeWritten / backfill pass fills the columns.
- `extractTakenAt` now delegates to a shared `extractIndexFields`
  so the metadata read path is one method, not two slightly-
  different ones drifting over time.
- Mapper SELECTs (`getEnrichedTimelineForUser`, `searchUserTimeline`)
  return width/height; the GROUP BY adds them to the keyed columns.
- `IndexController.composeItem` reads `photos-size` from the row
  first, falls back to the metadata blob for old rows that haven't
  been re-indexed since the migration. Frontend keeps reading
  `attributes['metadata-photos-size'].width/height` — same shape
  in the response, just sourced from the index table.

Bumped app version to `7.0.0-dev.3` so `occ upgrade` re-runs the
migration loader.

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