Folder search lowercases the query term, but album_path is VARBINARY and
compared byte-exact (case-sensitive) on MySQL/MariaDB, so a lowercased
term no longer matched uppercase folder paths. A parent folder still
appeared via its case-insensitive title, while child folders - matchable
only by path - dropped out of the results.
Add a dialect-aware PathLike helper: MySQL converts album_path to a
case-insensitive collation for the LIKE, SQLite and other dialects fall
back to a plain LIKE (SQLite is already ASCII case-insensitive). Only the
folder search filter is affected; byte-exact album_path identity lookups
are unchanged, so emoji and case-distinct folders stay distinct.
Pictures shared only through a folder, moment, calendar, or region link
have no photos_albums row, so the by-UID and by-hash visibility checks
reported them out of scope and returned "Entity not found" - for example
when downloading a single image from a shared folder. The photo and file
visibility helpers now fall back to the shared album's filter, matching
what browsing and full-album downloads already allow. The fallback runs
only after the cheaper personal-scope check misses and skips regular
albums, so full-access and regular-share sessions add no queries.
Add a string-valued Service.Tier field (OpenAI-scoped) that is sent
verbatim as a top-level service_tier field in the Responses API request
body, reusing the existing Org/Project plumbing. Defaults empty
(backward compatible, OpenAI "auto") and supports ${ENV} expansion.
Bounded exponential-backoff retry on HTTP 429 is a separate follow-up
on the shared client (#5729).
Make PHOTOPRISM_VISION_URI/KEY and OLLAMA_BASE_URL/OLLAMA_API_KEY
configurable through .env so Ollama Cloud can be tested without
editing compose.yaml directly.
- Default Service.Think to "false" for the Ollama engine via a new
EngineInfo.DefaultThink applied in ApplyEngineDefaults; re-enable
reasoning explicitly with Service.Think: "true".
- Replace the retired cloud default qwen3-vl:235b-instruct-cloud with
minimax-m3:cloud, and refresh the self-hosted default from
gemma3:latest to gemma4:latest.
- Warn on HTTP 404/410 (retired/renamed model) and other 4xx statuses
in ollamaParser.Parse so failures are diagnosable instead of silent.
- Defensively strip a leading, tag-delimited <think>...</think> block
from the response body.
- Update tests, regenerate testdata/vision.yml, and refresh package docs.
* Tests: fix random selection of deleted record causing test failure
* Tests: fix test folders being left after test
* Tests: ensure that required records are available
* Tests: make each package execute against it's own testdata folder to ensure no interpackage test failures
* Tests: add unit tests of CleanupTestFolder
* Tests: Allow defer in TestMain
* Tests: rename testMain to runTestMain
Only Vulkan and CPU (plus Intel SYCL) builds are installed now. Benchmarks
show Vulkan generally matches or beats the ROCm build for llama.cpp (except
some prompt-processing cases), while ROCm is far more complex to provision:
the prebuilt HIP backend needs a matching ROCm runtime, and consumer/iGPU
architectures such as gfx1150 additionally require HSA_OVERRIDE_GFX_VERSION
because rocBLAS ships no kernels for them. Vulkan drives NVIDIA, AMD, and
Intel GPUs on Linux with none of that setup, so auto-detection now selects
the Vulkan build for any supported GPU and the CPU build otherwise.
When an XMP sidecar provides GPS coordinates, call UpdateLocation()
immediately so the S2 cell is resolved and cached in the local DB.
Without this, subsequent re-indexes always triggered a live geocoding
request (even though the coordinates were already known), producing a
spurious error on every re-index pass.
Downgrade the geocoding failure log in photo_location.go and
places/cell.go from Errorf to Warnf: a transient network failure to
the places service is non-fatal (the photo is indexed successfully,
just without a resolved place name) and must not surface as an
indexing error in the test suite.
Installs prebuilt llama.cpp binaries from GitHub releases on AMD64/ARM64,
defaulting to the latest release or an explicit tag. Auto-detects the GPU
(sysfs PCI vendor IDs, lspci/nvidia-smi fallback) and picks ROCm, Vulkan,
or a plain CPU build, with a graceful fallback chain; NVIDIA GPUs get the
Vulkan build since upstream ships no CUDA build for Linux. Extracts into
<destdir>/lib/llama.cpp and symlinks the executables into <destdir>/bin
(the binaries carry RUNPATH "$ORIGIN"). Accelerator overridable via flags
or PHOTOPRISM_LLAMA_ACCEL; optional LLAMA_SHA256 verification.
Follow-up to the contributor change that moves shell customization out of
per-user home directories:
- Migrate the resolute, trixie, and questing (+ slim) dev images to the
shared install-bashrc.sh; the shared create-users.sh now hands users a
minimal skeleton, so images still writing to /etc/skel/.bashrc would
otherwise lose their aliases and prompt.
- Restore export PATH="$PATH:$HOME/.local/bin" (needed for claude/gh) in
the full dev images by writing it to /etc/bash.bashrc; kept out of the
slim and production images, which never had it.
- Guard the /etc/skel/.config copy in create-users.sh so production images
without it don't emit a spurious error, and use rm -f /root/.bashrc.
- Fix the install-bashrc.sh header reference and silence an intentional
SC2016 on the runtime-expanded PS1.
A recent Weblate update changed six French strings in fr.po without
recompiling fr.json, leaving the JSON out of sync. Recompile fr.json to
match, and fix "plutôt qu'au cliquant" -> "plutôt qu'au clic" (invalid
French) in the Open on Hover hint.
Recompile the frontend translation JSON files from the current .po
catalogs (the pulled Weblate updates plus the German/French/Hebrew
refinements) via `make gettext-compile`.
- German: use „Bereich" for the Folders hint to match the sibling
sections, and phrase the Open on Hover hint as an imperative.
- French: switch the new Accessibility and feature hints from informal
tutoiement to the infinitive/vouvoiement used elsewhere in the UI.
- Hebrew: use the singular imperative for Reduce Motion, „קטע" (section)
for the Albums hint, and align the Open on Hover hint with the label's
hover wording.
* Frontend: Add settings for albums, favorites, folders, and media features
* Frontend: Enhance download settings for files and albums #848
* Frontend: Enhance download settings for files and albums #848
* Frontend: Update album download settings to restrict access to super admins
* Frontend: Add album sorting options to settings for super admins
* Frontend: Update download settings visibility
* Frontend: Refine download settings access control for user roles
* Tests: Add album, folder, moment, state, and month order selection options to page-model
* Frontend: Update access control for super admins in settings to include scope check
* Tests: Add new tests for download name and album order settings persistence
* Tests: Implement new tests for default album sort order persistence in settings
* Frontend: Move default album order settings
* Frontend: Add zoom accessibility option in settings #799
* Frontend: Update download name from "Share Identifier" to "Share Friendly" and refine hints for download options in settings
* Tests: Add new acceptance test for disabling album downloads while allowing file downloads in settings
* Frontend: Improve download options layout and hints
* Frontend: Refactor download options condition in settings page
* Frontend: Implement native zoom control in lightbox component
* Settings: Add Collections tab and move Services to its own page #848#5429
- Add a super-admin Collections tab after Content holding the album
Download and Sort Order settings; remove those cards from Content.
- Move Services from a settings tab to a dedicated page
(page/services.vue) with a search toolbar and a Reload / Learn More
action menu; link it from the navigation settings menu.
- Implement server-side services search (acc_name filter) with tests.
- De-duplicate the picture sort-order options into a shared
SortOrderOptions builder in options.js.
- Update acceptance/unit tests and page models to match.
* Frontend: Regenerate translation catalogs #848#5429
Ran `make gettext-extract` to pick up the new settings strings
(Collections, Features, Disable Downloads, and related messages) so
Weblate can translate them. Existing translations are preserved.
* Frontend: Move lightbox viewport-zoom handling into the view helper
- Move the viewport pinch-zoom lock/restore (disableNativeZoom /
restoreNativeZoom + savedViewportContent) from lightbox.vue onto the
View singleton in common/view.js.
- Drive it passively from View.apply() via a disableViewportZoom flag in
the PLightbox case, alongside hideScrollbar / disableScrolling /
disableNavigationGestures; lightbox.vue no longer references zoom.
- Make save/restore idempotent and extract the viewport constants.
- Cover the helpers and the apply() wiring in view.test.js.
* Settings: Add Accessibility section to General with Reduce Motion #848#5429
- Move the page-zoom checkbox out of the feature-flags grid into a
dedicated Accessibility card (super-admin only) with four toggles:
Open on Hover, Reduce Motion, Hide Scrollbar, and Allow Page Zoom.
- Add a UI.ReduceMotion flag (defaults false) that toggles an
html.reduce-motion class via config.setReduceMotion and suppresses map
fly-to animations non-destructively (util.mapAnimateDuration).
- Make Open on Hover apply live by reading shouldOpenOnHover() through a
reactive computed in the action/auth/lightbox menus; Zoom and Hide
Scrollbar reload since they are baked into the server-rendered HTML.
- Reword two feature-grid hints to the "pictures" convention and cover
the new helpers with Go and Vitest tests.
* Frontend: Regenerate translation catalogs #848#5429
Ran `make gettext-extract` to pick up the new Accessibility settings
strings (Accessibility, Allow Page Zoom, Open on Hover, Hide Scrollbar,
Reduce Motion, and their hints) and the reworded feature-grid hints.
Existing translations are preserved.
---------
Co-authored-by: Michael Mayer <michael@photoprism.app>
TestIsWebDAVPath hardcoded the "/i/" proxy prefix, so it failed when
proxy.DefaultPathPrefix changed. Build the proxy cases from proxy.PathPrefix,
the same value IsWebDAVPath uses, so they track the configured prefix.
WebDAVSetFavoriteFlag logged sidecar mkdir/write failures at error level on
the UI log stream with the raw error, leaking the absolute sidecar path.
Route them through event.SystemError with clean.Error, matching the request
error handling in the WebDAV logger.
All WebDAV write-method errors (PUT/DELETE/MOVE/COPY/MKCOL/...) now go to
the console-only system log instead of the in-app errors table and browser
log viewer, because golang.org/x/net/webdav embeds absolute server paths in
its messages. Operators still see full detail in the container/service logs.
A MKCOL against an already-existing collection is a benign probe used by
sync clients such as PhotoSync to test for a directory before creating it;
it returns 405 and is logged at debug rather than as an error.
Read the standard exifEX:BodySerialNumber property, which ExifTool and
Adobe emit; the previous exifEX:SerialNumber matched no compliant XMP
sidecar and silently fell through to aux:SerialNumber.
Add deprecated TIFF-schema fallbacks still emitted by some writers:
tiff:Software (Software), tiff:ImageDescription (Description), and
tiff:Copyright (Copyright).
Apply the RawTherapee stderr rejection (raw.DecoderErrors) only for a
gated set of formats (default .cr3) instead of every RAW format.
RawTherapee prints "Cannot use camera white balance" for any sensor it
cannot read, including bodies whose default-WB render is fine, so for
formats it alone can decode (e.g. .raw, .kdc) the warning-based discard
left nothing to index. CR3 is unchanged: a magenta render warns and
falls back to the embedded preview.
dc:subject is Adobe's "Keywords" panel, but PhotoPrism's Keywords,
Labels, and Subject fields serve distinct purposes. Route dc:subject to
the descriptive Details.Subject field only (matching the embedded/
ExifTool path, where data.Subject comes from the dc:subject-backed
Subject tag and data.Keywords from IPTC Keywords), never the Keywords
field. Multi-word entries keep their spaces; the value stays searchable
via IndexKeywords and still drives label matching.
Lists the new OIDC authorization prompt option alongside the other
PHOTOPRISM_OIDC_ settings (in flag order) in the dev compose files and the
Plus Helm chart values/questions. Empty default preserves current behavior.
Add PHOTOPRISM_OIDC_PROMPT (--oidc-prompt) to forward the OpenID Connect
"prompt" parameter (login, select_account, consent) on the authorization
request, so a rejected user can re-authenticate or pick another account
instead of being silently signed in again. Unsupported values (including
"none", which would break interactive login) are dropped with a warning;
empty preserves the seamless-SSO default.
Reverts 0182c46bb. The webpack 5.107.2->5.108.0 bump changed prod-bundle
scope-hoisting so option-builders in src/options/options.js (Languages,
TimeZones, StartPages, MapsAnimate) threw "Cannot read properties of
undefined", breaking the login and Settings pages on all editions.
Re-apply the photo-sphere-viewer/tar patch bumps separately.
The babel-plugin-istanbul coverage plugin was only wired into the retired karma.conf.js; Vitest measures coverage via @vitest/coverage-v8 (.babelrc has no plugins). Dropping it removes the orphaned @istanbuljs/load-nyc-config -> js-yaml@3 subtree and clears the GHSA-h67p-54hq-rp68 DoS advisory; npm audit now reports 0 vulnerabilities.
Indexing workers share one classify.Model whose Run() built tensors into a single shared ImageTensorBuilder buffer with no lock, so concurrent classification corrupted images and produced garbage labels. Pool a per-call builder instead, restoring the pre-#5164 concurrency safety.
Add a concurrent regression test and consolidate the model benchmark.
Route JPEG XL through the native libvips path (govips jxlload) like
HEIC/AVIF, keeping the external "djxl" decoder as an automatic fallback
for runtimes whose libvips lacks JPEG XL support.
Decouple format enablement from "djxl": DisableJpegXL now stays enabled
when either the decoder is present or libvips can decode JPEG XL natively.
The libvips capability probe (thumb.JpegXLSupported) is cached and runs
only when no decoder is present, so config introspection on standard
installs (which ship "djxl") does not start libvips.
Add a shared assets/samples/dice.jxl fixture and focused tests for the
native conversion path, the djxl fallback command, and the capability
decision table.
Service discovery was probing the live https://www.photoprism.app/ and asserting
success, so the test broke when the website migrated to Hugo (no PROPFIND
support). Point it at a local httptest WebDAV-like server and allow the loopback
host via servicesCIDR, making the test deterministic and offline-safe.
On a cluster-OIDC Sign-Out the instance now delegates the Portal session and the
upstream RP-logout to the Portal's end-session endpoint instead of doing them
itself: revokePeerSessions skips the Portal peer's backend DELETE (still clearing
its storage), and DeleteSession skips clearing the OP session cookie when it
returns a providerLogoutUri — otherwise the Portal OP could not resolve the
session (via that cookie) to chain the upstream logout. onLogout now resolves to
the chosen landing URL so the /logout route guard follows the provider logout URL
too. Adds the oidcLogout client-config accessor and an instance.portal session
marker so the Portal peer is identified explicitly rather than by URL shape.
Sign-out of an OIDC session redirects the browser to the provider's
discovered end_session_endpoint (id_token_hint + absolute
post_logout_redirect_uri) when PHOTOPRISM_OIDC_LOGOUT is enabled, so the
provider SSO session ends and the next login re-prompts. Opt-in (default
off); local/LDAP logout and providers without an end-session endpoint fall
back to local logout.
Adds the shared CE /api/v1/oauth/logout route and OAuthLogoutHandler hook
plus the Portal OP end_session_endpoint advertised in the Portal discovery
document, for Portal builds to serve via the override hook.
interpolatePositional now matches only real Go fmt verb letters and disallows the space flag, so a literal percent in a translated message (e.g. "100% sicher") is not mistaken for a verb and corrupted. Adds a regression test.
Rename the Response struct fields to match their json tags (Err to Error, Msg to Message) and the Error() method to ErrorString() to avoid a field/method clash; update callers and tests. JSON wire format is unchanged.
Rename the i18n notification/error envelope fields from id/params to messageId/messageParams across the WS notify Data and the i18n.Response body, so they are unambiguous next to session, log, and correlation ids. Updates the frontend consumers (notify.vue, api.js), tests, swagger, and the event/locales READMEs.
The i18n.Response envelope now carries the untranslated source id and params; the frontend (api.js) renders them via Tp in the user's UI locale, with the server-rendered error/message as the instance-locale fallback. Common not-found/conflict/bad-request handlers were switched from hardcoded literals and raw err.Error() to Abort/i18n.Message helpers, so they benefit automatically.
FindByPath now resolves a path's containing filesystem by longest-prefix match against the raw mount list, instead of the df-style display filter that hides overlay/pseudo roots. This fixes PathInfo/Free/StorageLow for paths like "/" whose mount is hidden (e.g. a container overlay root), which previously resolved to an arbitrary masked tmpfs.
Describe the event *Msg structured payload and frontend per-user rendering in the event and backend-locales READMEs; fix a stale go-generate path (/internal/i18n to /pkg/i18n).
Route user-facing notifications through extractable $gettext (aliased ctx.$gettext is not extracted), wrap hardcoded English strings, replace concatenation with interpolation, and drop redundant English backend label toasts already covered by localized frontend ones.
Backend *Msg helpers now publish the untranslated message id and params alongside the rendered fallback; the frontend translates the id and applies a positional-placeholder shim so notifications render in each user's UI language.
Skip storing the inline database password in the chart-managed secret when
database.passwordSecretName references an external secret, fix the env-var
template formatting, document precedence, and surface the new values in the
Rancher questions form.
Add a README section mapping the current webpack build to its Vite
equivalents, and record the vue3-gettext 4.x ESM-only blocker (webpack
cannot resolve `fs` from pofile) that motivates the migration.
* Lightbox: Add 360° photo and video sphere viewer support
Wires Photo Sphere Viewer v5 into the lightbox for equirectangular
media. Backend detects UsePanoramaViewer and IsPhotosphere markers
alongside ProjectionType, and exposes Panorama + Projection in the
viewer / search result DTOs so the frontend can dispatch on the
projection type. The renderer (ThreeJS + PSV core + video plugin +
equirectangular video adapter) is lazy-loaded into a dedicated
webpack chunk so the base bundle is unchanged for users with no
360° media. PhotoPrism's existing lightbox controls drive the
underlying HTMLVideoElement that the PSV adapter creates for 360°
videos. Includes Vitest coverage for the sphere helper and the
lightbox dispatch, plus a TestCafe smoke spec and three new
ExifTool metadata fixtures.
* Lightbox: Fix 360° controls leak, hide zoom button, add sidebar icon
* Lightbox: Fix 360° touch panning and navigation arrows
* Lightbox: Fix fast swipe switching photos on 360° slides
Suppress PhotoSwipe swipe/drag navigation at its source via the dispatched pointer hook while a sphere slide is active, since the per-element gesture trap is outrun by a fast swipe whose pointer leaves the sphere container (touch-capable Windows). UI controls stay excluded so buttons and arrows still navigate.
* Lightbox: Route only equirectangular media to the 360° viewer
Routing was based on the loose photo_panorama flag for videos, which is also
true for cubemap and merely-wide (aspect > 1.9) videos, so non-equirectangular
media wrongly opened, distorted, in the sphere viewer. Route strictly on the
equirectangular projection for both photos and videos. Surface the video file's
projection in the viewer DTO via Photo.MediaProjection() (the primary search row
is a poster JPEG with no projection). Add an MP4 metadata-detection test.
* Lightbox: Open tagless 2:1 360° videos in the sphere viewer
Many 360° videos carry no projection tag PhotoPrism can read, so strict
equirectangular-projection routing left them in the flat player. Fall back to
equirectangular's defining 2:1 frame for panorama-flagged videos without a
projection, via is360Equirectangular() in common/sphere.js, while cubemap and
ultrawide (~2.35:1) clips stay flat.
* Tests: Make the 360° sphere video test assert instead of skipping
Bump abema/go-mp4 to v1.7.1 (only outdated direct dependency) and tidy
go.sum, pruning stale checksums left by the XMP sidecar merge. Regenerate
the license NOTICE for the updated dependency.
Use US-English spelling in the new XMP sidecar comments and tests, and
drop a specs/ reference from internal/meta/README.md so the public package
doc has no link into the private subrepo. Regenerate dialect_mysql.go from
SQL.
A "near=<uid>" geo search set the S2 cell but left the form lat/lng at
zero, so results sorted by distance from the (0,0) origin instead of
the referenced picture. Use the picture's own position in the ORDER BY,
falling back to time order when it has no usable coordinates.
Past the cluster zoom threshold, group un-clustered photo features by
projected screen distance instead of exact coordinates, so pictures at
the same place stack into one counter marker even when their stored
coordinates differ slightly (GPS jitter, tile quantization). Key each
stack by a stable member UID and open it by its bounding box so the
panel lists exactly those pictures.
Stop deriving a file's OriginalName from cached metadata during indexing.
The ExifTool JSON cache is keyed by content hash, so a renamed file was
re-indexed against the old name and surfaced it as OriginalName, making
cards show a stale name while the sidebar showed the current one.
OriginalName is now set only from the explicit import original name, so
plain-indexed files no longer carry one and imported files are unaffected.
Add pkg/txt.JoinOr plus acl.RolesCliUsageString and UserRoleUsageFor so the users/clients --role and --auth help derive from the role tables instead of hand-maintained literals, filtering the app/uploader aliases and visitor. Each edition lists its own roles; federatable contexts (LDAP, OIDC group-role, cluster grants) use ClusterInstanceRoles.
PActionMenu reads $util.shouldOpenOnHover() in data(); the suite only
passed because PActionMenu was stubbed. Complete the $util mock so the
tests no longer depend on that stub to hide the call, which otherwise
fails with "this.$util.shouldOpenOnHover is not a function".
Naming faces in quick succession left the second new name out of the
people type-ahead until a manual reload, because eviction relied on the
subjects WS event, which can lag the next focus-triggered read.
Marker.setName() and Face.setName() now seed the shared people cache with
the saved name so it is suggestible immediately; the rename path seeds via
recognized.vue (Subject.update() also backs hide/show, so it can't host the
seed). Harden the cache itself: get() snapshots an epoch so a fetch that
resolves after an evict/upsert no longer clobbers the slot with stale data,
and upsertPeople merges idempotently. Reload new.vue suggestions on focus
to match the photo people tab.
Group un-clustered geo features by exact coordinates so that pictures
sharing a spot render as one stack marker with a counter instead of
overlapping, hidden duplicates once zoomed past the cluster threshold.
Clicking such a stack opens the location's collection panel rather than
a radius search that surfaced unrelated nearby photos.
Key the embedded photo list by latlng so an already-open panel re-runs
its search for the newly selected cluster or stack.
Only warn when a request presents a token that is rejected; requests
without any credentials (e.g. the web app loading public config before
login) are routine and now logged at debug level, reducing log noise.
- Add scoped session revocation (RevokeSessions / RevokeDerivedSessions)
shared by the user-update API and the CLI: a privilege-level change now
revokes logins and app-password-derived sessions while keeping the app
passwords themselves, so configured devices keep working.
- Identify app-password-derived sessions by auth_method "session".
- Deny app passwords on the REST API when the account cannot log in
(DenyLogIn); WebDAV access stays governed by CanUseWebDAV.
- Reduce the WebDAV auth cache expiration to one minute.
- Add IsSystemOrInvalid; allow deleting the initial admin account and skip
re-initializing a deactivated or deleted admin in InitAccount.
- Move session-revocation scopes to pkg/authn and extend tests.
Guard recognized.vue onSave against re-submission while the people cache
resolves by setting busy before the async lookup. Drop updatePersonList so
Browse Pictures / Set Cover no longer evict and refetch the whole people
list; the name combobox reloads suggestions on focus and WS events keep
the cache current. Make the people-tab test seed via a mocked getPeople.
Move the people name list out of the client config into the shared
typeahead cache (getPeople, backed by GET /api/v1/subjects), matching
labels and albums. Consumers load suggestions on demand and the cache
evicts on people.* / subjects.* events and clears on logout; the
config.js people machinery is removed and ClientConfig keeps only
count.people for the navigation badge.
Groundwork for the people type-ahead cache: include the people list and
count in the client config only for roles that may view or search people,
and omit the list from the global config.updated broadcast. Clients load
it via GET /config and keep it current through people.* events.
helperAfterEach reverted photos before restoring albums, so re-adding a
photo to an album a test soft-deleted targeted a dead UID, 404'd, and
cascaded into helperFailures. Determine album changes first so the photo
loop can skip albums revertAlbums restores (recreated foreign-owned ones
get a new UID), and tolerate 404 on add-to-album.
Make the cameras/lenses/countries content-channel events publish only a
stable identity ([]string of slug or ISO code) instead of full entity
models, matching the UID-only invariant of every other content channel.
The update path no longer republishes count.* (an edit does not change
the count).
Restore surgical people updates: onPeople refetches only the affected
people by UID via Subject.search (refetchPeople) and upserts them in
place instead of forcing a full client-config reload (a #1307 regression).
Document the dormant folders.*/faces.* subscribers (no publisher, not
forwarded) and fix the misleading payload example in the event README.
Pin axios 1.18.0 (security-hardening minor: strips sensitive headers on cross-origin redirects, rejects malformed http/https URLs, tightens prototype-pollution defenses; no breaking changes). Bump vitest and coverage-v8 to 4.1.9, playwright to ^1.61.0. OSV-Scanner clean for both ecosystems.
GetDest() lazily wrote the shared ImportSettings.Dest on every call, so
parallel ImportWorker goroutines raced on it. Make GetDest side-effect
free and normalize an invalid stored pattern once in Settings.Propagate.
Also align the per-hash indexing lock added in #5652 with code style.
Mirror the lens make/model editing surface for cameras: entity UpdateMakeModel/SaveForm, form validation, query, search, the GET/PUT /api/v1/cameras endpoints, and the cameras CLI command, plus a cameras ACL resource and scope.
Also tidy the lens surface for parity: self-validating SaveForm, empty make/model guard, X-Count search header, service-role grant, the empty-id/slug docs, and order cameras before lenses everywhere.
Adds the ability to override a lens's Make/Model (e.g. fixing Pentax lenses that ExifTool decodes as `4 38`) via a new photoprism lenses update CLI command and a `PUT /api/v1/lenses/:id endpoint`, plus a `GET /api/v1/lenses` search endpoint, a new lenses ACL resource, and an lenses ls list command.
Adds four selectable app-icon variants with optimized SVGs and full PNG size sets. bloom and ring use a round canvas like logo; flower and shutter use the rounded-square canvas. Updates the frontend icon resolver, app-icon help text, and config tests, and strips editor data-name attributes from the existing icons.
Single-photo label add/remove/update, marker create/update/clear, file
unstack, orientation, and delete now apply the same shared-scope
visibility predicate as photo search and update, so by-UID and by-hash
edits stay consistent with album and photo updates and never act on
content outside the session's shared view. File-scoped handlers gate on
the resolved file's owning photo. Full-access sessions skip the check
with no database query via PhotoSessionSeesEverything.
VDataTable.color is forwarded only to the footer, where it sets the
items-per-page select's itemColor; "background" rendered the selected
option as low-contrast text-background. Use surface-variant to match the
regular <v-select> active color across all themes (it does not theme the
table — VDataTable/VTable never read color).
themeAssetUri now accepts a bare filename (logo.svg, the recommended
form since the theme author can't get the mount path wrong) as well as a
root-relative /_theme/... path, resolving either against the base URI so
they load on path-prefixed deployments. Absolute, protocol-relative, data,
and already-prefixed values pass through. Memoized via memoize-one (the
base URI is fixed once the config is loaded).
Cap the WebDAV PUT request body at OriginalsLimitBytes when an originals limit is configured, so a single upload cannot stream an unbounded body to disk past the per-file ceiling; no-op when unlimited. Adds a load-bearing regression test.
onOidcLogin arms the per-tab OIDC attempt guard instead of the post-login redirect-loop guard, so an authenticated return within the loop window resumes the stored deep link or Portal return_to rather than discarding it. Updates the login spec test to pin the guard choice.
Add a guest regression case asserting the like endpoint returns an in-scope picture with identifying metadata stripped, so dropping the per-session redaction call fails a test rather than regressing silently.
Apply the same per-session visibility gate and response redaction the photo read and update endpoints already use, so the favorite (like/dislike) routes return only what the session is entitled to see. Adds guest regression subtests for both routes. #1307
The status checks captured redirected command output, so the csf and firewalld branches never evaluated correctly (firewalld was reported active unconditionally). Use systemctl is-active --quiet instead.
Stage each download in a temporary file and verify it against the SHA-256 published by the upstream release (s6-overlay per-asset .sha256, yt-dlp SHA2-256SUMS) before installing. Abort on mismatch and warn when no manifest is published so older pinned tags still install.
The goproxy caching module proxy was a temporary workaround for build rate limits that no longer apply, and nothing references the published image (no compose service, CI step, or GOPROXY target).
Bumps the go.mod toolchain directive and the dummy OIDC fixture to
go1.26.4, clearing the GO-2026-5037/5038/5039 stdlib advisories. Updates
golang.org/x/net to v0.56.0 (NOTICE regenerated) and the fixture's
golang.org/x/sys to v0.46.0. osv-scanner --recursive returns zero
advisories and the full Go suite passes.
Single-photo update/approve/set-primary and batch archive/restore/approve/private/delete now apply the same shared-scope visibility predicate as photo search, so by-UID and bulk edits stay consistent with album updates and never act on content outside the session's shared scope. Visibility is evaluated against both the client and user role for client sessions. Full-access sessions skip the check with no database query via PhotoSessionSeesEverything / SelectedPhotoUIDsForSession.
Normalize separators, reject drive-letter prefixes, and verify
containment with filepath.Rel instead of a string prefix, matching the
pkg/fs ZIP safe-join. Extend TestJoinUnderBase to mirror TestSafeJoin
coverage and lock the handler surface via WebDAVFileName.
A theme-provided icon (theme variables.icon, e.g. /_theme/logo.svg) was
used verbatim, so on a path-prefixed deployment the browser requested it
without the /i/<node> base and got a 404 (the custom logo silently fell
back). Resolve it through a themeAssetUri helper in getIcon/getLoginIcon;
absolute, protocol-relative, data, and already-prefixed paths pass through
and root deployments are unaffected.
Security-hardening and bug-fix minor release; no breaking changes for
our usage. Keeps the exact pin (no caret) per the high-risk-package
policy. Verified with make audit, OSV-Scanner, build-js, and test-js.
An OIDC sign-in failure (e.g. a cluster 'no access to this instance'
denial) was shown only as a transient toast the user could miss and
retry into a denial loop. Render it as a persistent, dismissible alert
on the login form instead, driven by the existing session.error bridge.
Also mark the redirect-loop guard when the user clicks the OIDC button,
so a bounce-back is recognized like the /login route guard's auto-redirect.
When an OIDC login matches a pre-existing non-OIDC account, log a
specific operator remedy (the exact 'users mod --auth oidc --auth-id'
command) to the audit and login-error logs instead of a bare provider
mismatch; the user-facing page stays a generic credentials error so the
account is not disclosed. Add a 'users mod --auth-issuer' flag so the
documented manual reconcile can pin the OIDC issuer, scoping the link to
that provider instead of matching any issuer with the same subject.
AppName() resolves to an explicit AppName, then SiteName (SITE_NAME), then SiteTitle. SiteTitle() falls back to SiteName when neither a SiteTitle nor an AppName is set, so an instance branded only via SITE_NAME stays consistent.
Adds an AppPasswords feature flag (default on) so app passwords can be disabled, closing the bypass where an app password minted by an OIDC-provisioned user keeps working after the account expires. When off, minting (oauth/token and auth add) is rejected and existing app passwords are no longer accepted on the REST API (403) or WebDAV (401). App passwords are identified by their session auth provider (IsApplication), covering every grant type. The Apps and Devices affordance is hidden for sessions that may not change a password; operators can force it off via PHOTOPRISM_DISABLE_FEATURES.
The relying-party state and PKCE cookies were left at the zitadel default Path=/, so on a shared-domain proxy (instances served under /i/<tenant>) they reached the callback only if the proxy rewrote the Set-Cookie path. They are now scoped to <base>/api/v1/oidc, covering both the login leg and the redirect callback, so they survive to the callback regardless of any proxy rewrite.
A user who signed in via cluster OIDC now returns to the Portal login (re-auth via OIDC) on sign-out instead of the instance's local form. The /logout route awaits the cluster-wide sign-out so the Portal OP cookie is cleared before the redirect, avoiding a silent re-SSO.
The OIDC RP callback handler returned without writing a response when
CodeExchangeUserInfo failed (e.g. a missing/expired state cookie), leaving
the raw zitadel error ("failed to get state: http: named cookie not present")
on screen with no way forward. CodeExchangeUserInfo now runs the exchange
against a recorder so the handler's raw error never reaches the browser, and
the callback renders the branded auth.gohtml page (which returns the user to
login) instead of a dead-end. Set-Cookie headers from a successful exchange
are still propagated.
- /logout route guard now signs out cluster-wide: session.signOut shares
revokePeerSessions with logoutEverywhere, so a direct /logout revokes peer
sessions and clears shared storage too, not just the current session.
- Decouple PHOTOPRISM_CLUSTER_OIDC from the auto-join/theme toggles: InitConfig
now always runs resolveNodeOIDCClient for an instance (join/theme moved into
bootstrapClusterNode), so a registered node re-wires its OIDC RP on restart
even with both bootstrap toggles disabled.
- Map redirected OAuth server_error / temporarily_unavailable to ErrUnexpected
instead of "Invalid credentials", so a Portal-side failure isn't shown as a
credential problem.
logoutEverywhere read and cleared peer sessions through this.localStorage /
this.sessionStorage, which are NamespacedStorage wrappers (getAppStorage).
Enumerating or clearing a cross-namespace key through a wrapper double-prefixed
it (pp🅰️pp🅱️...), so peer session.token keys were left behind after Sign Out
even though the server sessions were revoked. Unwrap to the raw underlying store
first. The vitest case now constructs the session with a NamespacedStorage, as
the app does, so it would catch the regression.
Implements three OIDC follow-ups from specs/portal/cluster-oidc.md:
- Zero-touch instance OIDC RP config: PHOTOPRISM_CLUSTER_OIDC derives the
OIDC RP client_id/secret from the node client credentials and defaults
the issuer to the instance's own origin (scheme.OriginURL), so a single
boot reaches a working Portal login. Explicit oidc-client/secret/uri win.
- Human-friendly authorize errors: redirectable errors 302 back to the
validated redirect_uri; non-redirectable ones content-negotiate a
branded HTML page (oauth-error.gohtml) vs JSON via shared helpers in
internal/api/oauth_error.go. The RP callback surfaces inbound ?error=.
- Cluster-wide Sign-Out (Tier 2): session.logoutEverywhere fans out a
best-effort DELETE per peer session, clears their storage, then signs
out locally; session-delete clears the OP cookie on any shared-domain
OIDC-against-Portal node (OIDCSessionCookieClearPath).
Show the instance app icon and base path in the switcher, persisting the icon
alongside the SiteUrl and title per namespace (default to the logo when none is
set). Add a Manage Account item gated on the account feature, and open the menu
on hover like the other action menus.
Move component/navigation/user-menu.vue to component/auth/menu.vue (and the
matching Vitest), and add an mdi-dots-horizontal affordance icon to the user
info row so the avatar overlay menu is discoverable.
Replaces mdi-dots-vertical with mdi-dots-horizontal for the navigation
mobile menu trigger and the p-action-menu default icon, and rotates the
lightbox PhotoSwipe menu-button SVG to match (kept centered in its 16x16
viewBox).
showAccountSettings moved out of navigation.vue when the instance switcher
became the shared user menu; account settings is now a declarative sidebar
link. The avatar menu stays covered by user-menu.test.js.
These pages only exist as Plus/Pro/Portal overlays; in editions without
the overlay they now redirect to the user's default route via
$session.getDefaultRoute() instead of rendering a placeholder.
Add the per-file alias so portal vitest resolves the shared common/user-format formatters to the portal overlay, matching the existing common/instance-grants entry.
Skip user-less sessions whose signal NoUser() rejects, warn on the ephemeral key fallback, and keep a manager's delete-by-ref-id from wiping its own OP cookie.
Thread admin authorization into SaveForm via byAdmin so the user-less cluster service principal can persist login/role/WebDAV instead of silently dropping them.
Honor the pp_role claim (gated by pp_issuer_kind) over the group mapping so an instance login reflects the role the Portal granted, restricted to federatable roles.
The explicit overlay z-index already keeps the teleported menu above the sidebar,
so anchor it back to location=top (above the avatar) instead of opening it to the
right of the navigation.
The menu content teleports to body, so a nav-scoped z-index does not apply. Set
an explicit overlay z-index and open it beside the drawer (location=end) so it is
no longer partially covered by the navigation.
Add a shared component/navigation/user-menu.vue (Settings / Switch Instance /
Log Out) wired into the navigation avatar, so every edition gets the switcher.
Instances record their identity under their storage namespace at session auth and
clear it on logout (common/instances + session.js), and the menu lists same-origin
peers that hold a live session. Keeps the distinctive base-path instanceLabel and
the post-login redirect loop guard. Replaces the bootstrap-time directory recording.
The OP session cookie stored the raw bearer token with a full-session lifetime,
so a leak of the cookie value was a usable credential against the whole API. It
now carries an HMAC-signed reference to the session id (a hash of the token,
useless as a bearer) with a 10-minute TTL refreshed on each session GET. The key
is persisted under the Portal config keys directory. The authorize handler
resolves the session via OIDCSessionCookieSession. Needs a coordinated SSO e2e
round in the Portal test env before merge.
Lets the Portal admin/users.vue page (which keeps the common/instance-grants
overlay) be imported under the portal vitest config so its methods can be
unit-tested.
Gate the OIDC relying-party cookie handler's WithUnsecure() on the existing
insecure flag instead of applying it unconditionally, so the round-trip state
(CSRF defense) and PKCE code_verifier cookies retain the Secure attribute on
HTTPS deployments. Addresses audit finding OJW-260603-02 (GO-HTTP-005).
Move the Portal OIDC OP authorize/token/userinfo onto the shared CE
/api/v1/oauth/* routes (the reserved #4368/#4369 stubs) and drop the parallel
root /oauth/* set.
- Add overridable handler hooks (oauth_handlers.go) mirroring
server.WebDAVHandler; OAuthToken delegates only the authorization_code grant
(gated on a form content type) and keeps client_credentials/password/session
unchanged; userinfo now serves GET and POST.
- Scope the OP session cookie to /api/v1/oauth (base-path aware) so the browser
still sends it to the moved authorize endpoint instead of looping.
- Stop the proxy re-scoping instance->Portal authorize redirects
(portalRootPathPrefixes -> /api/v1/oauth/).
- Point discovery at issuer + /api/v1/oauth/*; fix the JWKS URL base-path
doubling for sub-path deployments (also in CE openid.go).
OIDCGroupRoles and OIDCRole drop non-federatable roles, and the OIDC redirect
apply paths use acl.FederatedRoleUpdate, so an external identity provider cannot
assign cluster_admin/visitor as an account role or change an existing operator
account on login.
Adds the Portal-only cluster_admin operator role (declared in CE, registered
only on Portal builds), the acl.AdminRoles/IsAdminRole admin-tier set, and the
acl.IsFederatedRole/FederatedRoleUpdate helpers that keep cluster_admin and
visitor out of externally mapped account roles. SuperAdmin resolves to
cluster_admin on registered builds, SaveForm keeps an admin-level role on a
self-save, and UpdateUser rejects self-role changes. RoleStrings.Strings hides
visitor and none from the CLI role help.
Multi-segment events now log as "config: database › connect" instead of
"system: config › database › connect"; single-segment events keep the
generic "system: " prefix. The hub message is unchanged so the frontend
log viewer still shows the full joined event.
OptionsReportSections and YamlReportSections started the Storage group at
config-path, but storage-path and storage-free precede it in the flag and
field order, so they were rendered under the preceding Logging section in
show config-options / config-yaml and the generated reference docs. Anchor
the Storage section at storage-path so those options group correctly.
Add a GetFile subtest asserting a shared-only (guest) session receives no
file details from /api/v1/files/:hash, locking in the per-session access
behavior. Per-photo file scope is covered separately by
search.TestFileVisibleToSession.
The runtime free-space probe can misreport on network mounts, fuse
layers, and container overlays, so default it off as a safety measure.
The default lives in the config layer (DefaultStorageFree = -1) rather
than in the disk package, whose DefaultStorageLowPct stays 1.0. Test
configs build options directly (StorageFree = 0), so they keep falling
back to the disk default and the existing storage tests stay enabled
unchanged; only the production flag default disables the probe.
Operators opt in with a 1-99 percentage.
Record that album_path is now a byte-exact VARBINARY column (alongside
album_slug, album_filter, photo_path) and that folder albums dedupe by
album_filter, in internal/entity/README.md. Add an Index Prefix Limits
note to the migrate README and AGENTS: InnoDB caps key prefixes at 767
bytes on COMPACT/REDUNDANT row formats, so VARBINARY prefix indexes use
512 by convention.
album_path mirrors photos.photo_path (already VARBINARY(1024)) and is
compared against it directly, but was VARCHAR with the utf8mb4_unicode_ci
collation, which collapses case and most emoji. That made album_path = ?
lookups collation-fuzzy and forced byte-exact re-checks in Go.
Store it as VARBINARY(1024) so folder album path comparisons are
byte-exact at the database, consistent with album_slug, album_filter,
and photo_path. The MySQL migration preserves the existing 768-byte
prefix index. SQLite compares TEXT byte-exact by default, so no SQLite
migration is needed.
tempPath() builds its fallback from os.TempDir(), which is not always
"/tmp" (e.g. when TMPDIR is set), so the hardcoded "/tmp/photoprism_"
prefix made the test fail in such environments. Derive the expected
prefix from os.TempDir() instead, and reset the cached TempPath() global
so the assertions no longer depend on test execution order.
Folder album slugs are not unique identities: long nested paths are
truncated to ClipSlug runes and slug.Make drops emoji, so distinct
sibling folders can share one album_slug. Deduplicate folder albums by
album_filter (the serialized path) instead, and keep slug matching only
for non-folder album types.
This stops RemoveDuplicateMoments from deleting legacy folder rows with
an empty album_path that merely share a truncated slug, which the
indexer then recreated on every run. The empty/NULL album_path escape
hatch in the previous byte-exact path guard no longer applies.
The registry test config isolates each test via its own SQLite file, but on
MariaDB every test shares the acceptance database, so List()'s global node query
also returns nodes created by other tests. Assert that the expected node is
listed and the non-node records are not, via a listNodeByName helper, instead of
an exact result count.
Add internal/entity/README.md covering the second-precision timestamp behavior,
the time helpers, MariaDB strict-mode test gotchas, and the utf8mb4 emoji
collation pitfalls so they don't have to be rediscovered.
The schema stores created_at/updated_at as DATETIME without fractional seconds,
so set gorm.NowFunc to entity.Now() (UTC truncated to the second). This keeps
in-memory and persisted times in sync and makes timestamp comparisons behave the
same on SQLite and MariaDB. Adapt the affected tests to compare with Sub() in a
sane range or a clearly past base instead of strict sub-second ordering, and give
the duplicate-client registry test distinct update times.
MariaDB strict mode rejects empty primary keys and out-of-range or oversized
values that SQLite accepts. Give the affected entity tests valid IDs: existing
fixtures where the row is additive, and throwaway in-range IDs where a real
reference would overwrite seeded data.
CreateUserSettings inserted an empty UserSettings, leaving the user_uid
primary key blank. Build it with NewUserSettings so the row carries the UID;
MariaDB strict mode rejected the missing value.
Legacy folder slugs drop emoji (slug.Make), so two siblings collide on the
VARBINARY album_slug and RemoveDuplicateMoments deleted one each pass while
the indexer recreated it. Skip folder albums whose album_path differs
byte-exact via HEX(), which compares the same on MariaDB and SQLite.
MariaDB's utf8mb4_unicode_ci collation collapses most emoji, so album_path
lookups matched a sibling folder and merged the two albums. Re-check the
path byte-exact in Go in FindFolderAlbum, the UpdateFolder dedup, and the
slug-collision scope check.
Add Formats.Match to report which exclude-list entry matched, and use it in the
remux, download, transcode, and index skip messages instead of always printing
the codec (which could be empty or differ from the entry the user configured).
Also apply the exclude list when building "videos transcode" plans so excluded
files are skipped during dry runs and real runs, and report exclusion clearly
when no preview image can be generated for an excluded video.
Combine the HEVC and MagicYUV sample-entry scans into one pass over the file
head, so the probe reads the head only once when go-mp4 does not report the
codec directly. Rename the scan limit to HeadScanLimit and group the
sample-entry chunk sets in brands.go.
Recognize MagicYUV sample entries in the built-in go-mp4 probe and consider the
probe's codec in addition to the metadata codec when applying the FFmpeg exclude
list, so a video can be matched even when one detector misses it. Rename the
shared sample-entry scan limit to CodecHeadScanLimit.
Also exclude the Matroska Video for Windows wrapper (v_ms/vfw/fourcc) by default,
since its actual codec is not exposed by metadata and the format is uncommon.
Read the video codec from the AVI VideoCodec metadata tag instead of falling
back to the container name, so codec-based entries on the FFmpeg exclude list
are applied during indexing.
Match exclude-list codecs by their canonical alias so the original name
reported by a metadata tool (e.g. m8ra), the human-readable name (magicyuv),
and the canonical FourCC (magy) are treated as equivalent. Add h264/h265
aliases and make magy the canonical MagicYUV codec name.
Add a vulkan target to scripts/dist/Makefile so PHOTOPRISM_INIT="vulkan"
installs mesa-vulkan-drivers and vulkan-tools directly, and extend
install-gpu.sh to provision the Mesa Vulkan drivers for detected Intel and AMD
GPUs plus vulkan-tools for NVIDIA verification.
Add README files for the vaapi, intel, nvidia, and vulkan encoder packages
covering the FFmpeg flags, devices, supported formats, required system packages,
and FFmpeg 8 notes such as the VA-API filter-device requirement.
encode.FormatNV12 already ends in hwupload, so appending it again produced
"format=nv12,hwupload,hwupload", which fails because the frames are already on
the GPU. Build the filter from FormatNV12 directly to upload exactly once, and
add a gated Vulkan case to the transcode command tests.
The transcode tests gated their real hardware run on PHOTOPRISM_FFMPEG_ENCODER,
but the package init() unconditionally unset that variable, so the hardware path
never executed and an FFmpeg 8 regression like #5630 could pass unnoticed.
Switch the opt-in to a dedicated PHOTOPRISM_FFMPEG_TEST_ENCODER variable so real
VAAPI/QSV/NVENC transcodes run when enabled on a matching host, while a runtime
PHOTOPRISM_FFMPEG_ENCODER value still cannot trigger them by accident.
FFmpeg 8 no longer derives a filter hardware device from "-hwaccel
vaapi" alone, so the "format=nv12,hwupload" step aborts with "A hardware
device reference is required to upload frames to." and PhotoPrism falls
back to software encoding.
Initialize a named VAAPI device and reference it for both decoding
(-hwaccel_device) and filtering (-filter_hw_device). When no device path
is configured, FFmpeg auto-detects the default render node.
Replace strings.Split with the lazy strings.SplitSeq iterator (Go 1.23+)
in loops that only range over the tokens, avoiding the intermediate
slice allocation. The yielded substrings are identical to Split, so
behavior is unchanged. Applies the suggestion from #5599 to its three
sites (CIDR parsing, WebDAV hidden-path, overlay path checks) and
extends it to two more split-and-iterate loops in the server compression
negotiator and MCP config-options builder for consistency. The slice
returning splitPath helper is intentionally left on strings.Split.
Split the catch-all values.go into one file per concept, each mirrored
by its test: distance.go, norm.go, stats.go, product.go, centroid.go,
plus the mean methods folded into mean.go and Copy/Dim/Sum into
vector.go. Remove values.go, values_test.go, and values_more_test.go so
functionality and tests live where developers expect them.
Hoist the two 512-dimensional face embeddings shared by the distance,
norm, and cosine tests into fixtures_test.go, removing the previous
triplication, and decompose the monolithic TestVector into per-concept
tests. Close pre-existing coverage gaps in the integer converters and
the GeometricMean/HarmonicMean method wrappers, bringing the package to
100% statement coverage. Pure code movement; no behavior change.
Prepare pkg/vector for a future refactoring of internal/ai/face without
touching the face package yet. Normalize scales a vector to unit length
(L2) in place and leaves a zero vector unchanged; Normalized returns a
normalized copy. Centroid returns the element-wise mean of a set of
vectors, ignoring entries whose length differs from the first and
averaging over the vectors actually included. Together with the existing
EuclideanDist, EuclideanNorm, and NewVector helpers these cover the math
currently hand-rolled in the face embeddings code. Fully covered by
tests (100% of the new functions).
The root file of the vector package was missing the standard package
description and AGPL header required by the Go style rules. The vendored
alg subpackage keeps its own MIT license and is left unchanged.
CosineDist now returns a true cosine distance (1 - similarity), so
identical vectors yield 0; the previous similarity behavior moves to a
new CosineSimilarity function/method. GeometricMean no longer treats a
zero element as an uninitialized accumulator: zeros now correctly yield
0 and negative inputs return NaN. NewVector copies float64 and Vector
inputs so the result is independent of the source slice, matching the
other converters. Norm uses absolute values so odd powers (L1) stay
well-defined for negative components, variance is simplified, and the
numeric error paths return NaN instead of the Epsilon sentinel. Add
focused tests for the mean, distance, norm, and copy helpers.
Extract the per-worker scan range size into partitionSize() and floor
it at 1 so the nearest() dispatch loop always advances and terminates,
even if the worker count exceeds the number of data points. The size
based numWorkers buckets keep points >= workers today, so this is a
defensive guard against future tuning. Add focused tests for the helper
and a high-worker end-to-end case.
The vendored OPTICS implementation was never invoked: face clustering
uses DBSCAN (alg.DBSCANWithProgress), and OPTICS() had no callers or
tests. Removing it also drops a latent no-op loop (PVS-Studio V8016)
in extract(). Drop the now-orphaned errZeroXi and update the rangeJob
comment and README to match. See discussion #5628.
FileVisibleToSession excludes soft-deleted file rows; PhotoVisibleToSession's UnscopedDb use is documented (archived handling deferred to ScopeVisiblePhotos, full-access short-circuits). Adds TestUserPhotos_ScopeAuthorization pinning that a restricted session may scope only to albums it owns or has shared.
RedactForSession also clears the camera serial, XMP DocumentID, and per-file InstanceID for shared-only/unregistered sessions, matching common EXIF/XMP privacy practice; FileName, PhotoUID, and FileUID are kept.
fetchPhoto and preloadNextPhoto no longer skip GET /photos/:uid for shared-only sessions; the endpoint reduces detail server-side, so the sidebar preloads for every session and the per-section ACL gates decide what renders.
GetPhoto returns a reduced view for sessions limited to shared content: album membership is restricted to the albums shared with the session, and labels, people, and owner/storage metadata are omitted. Full-access sessions are unaffected. Adds Photo.RedactForSession, in preparation for upcoming sharing features.
GetPhoto, GetPhotoYaml, and UpdatePhoto now limit access to pictures within the session's shared scope via the shared search helpers, with no change for full-access sessions. GetAlbum reports out-of-scope albums as not found for consistency with photos and files. In preparation for upcoming sharing features.
GetFile and the zip selection now limit results to the session's shared scope via the shared search helpers, consistent with photo search and in preparation for upcoming sharing features. No change for full-access sessions.
Introduces ScopePhotosForSession, ScopeVisiblePhotos, PhotoVisibleToSession, and FileVisibleToSession, and refactors searchPhotos to reuse the shared predicate, in preparation for upcoming sharing features. Full-access sessions short-circuit without an extra query.
Define npm clean-install, install, update, and audit commands once in the
root Makefile and have frontend/Makefile delegate via $(MAKE) -C .., so the
single root node_modules and package-lock.json are used. Standardize the
--ignore-scripts/--no-audit/--no-fund/--no-update-notifier flag order across
all npm invocations and drop the no-op --no-audit from npm audit.
FixPrimaries counted soft-deleted files as primaries, so a photo whose
primary was deleted kept photo_quality = -1 and stayed in Hidden even
with a present, valid preview file. Also clear the primary flag from
soft-deleted files so a present file is promoted and quality recovers.
The thumbs command wrote new previews into the thumb cache regardless of
free disk space, unlike the indexer and converter. Add a start-of-scan and
per-file storage check mirroring Index.storageLow() so generation aborts
with a clear, localized message when the storage volume is critically low.
A command that rewrites the in-memory database connection (e.g. cluster
register persisting provisioned MySQL credentials) made the post-command
RegisterDb() block in connectDb's 60s retry loop reaching an unreachable
server, silently delaying the commands test suite. Snapshot the connection
settings before the run and restore them before re-opening so follow-up
checks reconnect to the per-suite SQLite test DB.
Gate the upload dialog and import page on a new $config.insufficientStorage()
helper (files quota reached OR storage folder low), mirroring the backend
Config.InsufficientStorage(). Fix the upload dialog test config mock and add
unit coverage for the new config helpers.
The Portal OIDC OP at /oauth/authorize redirects unauthenticated browsers
to /portal/admin/login?return_to=<authorize URL> via a top-level
navigation, so the global router guard's setLoginRedirectUrl path never
runs and the deep link is lost. The /login route now reads return_to
itself, validates it via safeReturnTo (root-relative paths or same-origin
absolutes only; protocol-relative and backslash-prefixed values rejected
as open-redirect bait), and records it through the existing
loginRedirect machinery so followLoginRedirectUrl honors it after a
successful local or LDAP login.
Operators can now tune the Portal OIDC OP without recompiling. Four new
options thread through the standard yaml/CLI/env layers:
- PHOTOPRISM_PORTAL_OIDC_ISSUER override for the discovery /
ID-token issuer URL (defaults to
site-url)
- PHOTOPRISM_PORTAL_OIDC_TTL access/ID-token lifetime, clamped
to [60, 900] s; default 300
- PHOTOPRISM_PORTAL_OIDC_CODE_TTL authorization-code lifetime,
clamped to [30, 300] s; default 60
- PHOTOPRISM_PORTAL_OIDC_DEFAULT_POLICY chooser | direct; default chooser
Config.PortalOIDC{Issuer,TTL,CodeTTL,DefaultPolicyChooser} expose the
values for handler use. The OIDC handler wiring to read them ships in the
portal subrepo.
The portal proxy was prefixing the per-instance path scope onto absolute
Location headers whose host matched the proxy, breaking instance-to-Portal
redirects like the Pro OIDC RP pointing at the Portal authorize endpoint.
Paths under /oauth/, /.well-known/, and /portal/ are now recognized as
Portal-root and left untouched.
The backend setters Album.SetTitle and Label.SetName silently truncate
with an ellipsis once the input exceeds 160 chars and still return 2xx,
so the three remaining "create new" surfaces showed a green success
against an entity the user did not intend. Refuse the save client-side
when the typed name exceeds the per-entity MaxLength: the Add-to-Album
combobox in component/photo/album/dialog.vue, the Albums + Labels chip
selectors in component/photo/batch-edit.vue (gated through a new
max-length prop on component/input/chip-selector.vue), and the Labels
tab combobox in component/photo/edit/labels.vue all surface a "Name is
too long" error instead of dispatching the request.
Drops the stray second colon at the end of the "Only starts if … profile
is specified:" comment block in compose.nvidia.yaml and the three
setup/docker compose files. Pure cosmetic cleanup so all gated services
use identical phrasing across the dev and end-user compose variants.
Compose reads COMPOSE_PROFILES from .env automatically (no --profile flag
needed on every invocation), so adding it to .env.example lets developers
opt into the new "postgres" profile (or any other) once and forget about
it. Drive-by trims "Only starts this service if..." to "Only starts if..."
across compose.yaml, compose.nvidia.yaml, and the three setup/docker
compose files so all gated services use the same shorter phrasing.
The default development stack runs on MariaDB (PHOTOPRISM_DATABASE_DRIVER
is "mysql", and the photoprism service depends_on only "mariadb"), so the
postgres container should not start on a plain "docker compose up". Add
profiles: [ "all", "postgres" ] so users opt in via --profile postgres,
align every gated service to the same image / comment / profiles /
stop_grace_period shape, add the missing per-block "docker compose
--profile X up -d" examples, fix pre-existing "::" typos, and correct
"qsql" to "psql" in the photoprism service environment comments across
compose.yaml, compose.intel.yaml, and compose.nvidia.yaml.
libheif 1.21 renamed the CLI binaries: heif-convert is now a symlink to
heif-dec and heif-thumbnailer is no longer shipped at all. Updating the
header and inline comments in install-libheif.sh and build-libheif.sh
to match what the scripts actually produce on current libheif releases.
- Refactor `meta.css` face-marker palette to consume theme tokens (Path A):
default rect → `on-surface`; named-marker stroke → `primary`; draft →
`accent`; hover → `primary-darken-1`; handle → `primary-darken-1` fill +
`primary` stroke; Confirm pill → `primary-darken-2`; Cancel pill →
`highlight-lighten-1`; Remove pill → `remove`; Back button → `surface`.
- Retune the `lightbox` theme block to support the rebind: `primary
#F2F3F3 → #9E8FC9` (muted purple), `accent #2D2E2E → #BDAFE4` (lavender),
`surface #151515 → #181818`, `surface-bright → #1c1c1c`, `button →
#242424`, `highlight #424041 → #3c3c3c`, `remove → #cd4645`. Effectively
reverts the `surface-variant ↔ on-surface-variant` swap and re-introduces
an explicit `.v-tooltip.v-theme--lightbox` override in `lightbox.css`.
- Bump `variations.darken` to 2 so `--v-theme-primary-darken-2` resolves.
- Swap the in-template button order in `markers.vue` to Cancel-left /
Confirm-right for both the pending-add and remove-confirm popovers.
- Add inline comments to the `default.add` and updated `lightbox`
entries so each color's purpose stays documented at the source.
- Drop `eslint-plugin-prettier` (its `prettier/prettier` rule was already
`"off"`, making the plugin inert). Replace `plugin:prettier/recommended`
with a direct `eslint-config-prettier` extend; keep ESLint's own
`indent` / `quotes` / `brace-style` rules as the JS formatter.
- Tell ESLint to ignore `*.{css,scss,sass}` so Prettier owns CSS
unambiguously; wire `npm run fmt-css` / `npm run lint-css` Prettier
passes into `npm run fmt` / `npm run lint` so `make fmt-js` and
`make lint-js` cover stylesheets too.
- Drop `eslint-plugin-prettier` from the dev-container global install
list and the `frontend/tests/README.md` dependency table; regenerate
`NOTICE` files.
- Centralize sidebar/PhotoSwipe caption link styling via new `.text-html`
class in `text.css`; drops the previous `info`-token overload.
- Swap lightbox `surface-variant` ↔ `on-surface-variant` so Vuetify
tooltips render dark natively (remove `.v-tooltip` override). Lift
`on-surface` to `#f9fafb`, `primary` to `#F2F3F3`, `highlight` to
`#424041`.
- Add cross-theme `add` color token to `ui.js` (dark `#3a6e73`, light
`#cce3de`); register `add` and `remove` in Vuetify color variations.
- Rename chip-selector classes from color-named to action-named
(`chip--add` / `chip--remove` / `chip--default` plus `--mixed` partial
variants); double selectors (`.chip.chip--*`) to win over the UA
`[role="button"] { color: inherit }` reset.
- Tune `remove` saturation in `ui.js`, `default`, and `lavender` themes.
- Bind face-marker chrome in `meta.css` to theme tokens: default rect +
removing rect → `on-surface` / `remove`; Cancel pill → `button`;
Remove pill → `remove`; pill text → `on-surface`.
- Remove the hardcoded `menuBgColor` literal in `lightbox.vue` and the
redundant `.meta-caption a:hover` rule in `lightbox.css`.
- Update chip-selector vitest for renamed classes; Prettier auto-format
in `views.css` and `vuetify.css`.
Removes the top-level "escape-string-regexp" dependency, which has no
direct consumer in our source. The v4 line lives on as a transitive
dep of "eslint" and "sanitize-html"; "webpack-bundle-analyzer" keeps
its nested v5 copy. Net dep-tree shape is unchanged.
Also bumps "@vue/language-server" to ^3.3.0, adds "vuln" and "tools"
phony aliases in the Makefile, and aligns the frontend "audit" target
with the root one (--no-fund --no-audit).
Signed-off-by: Michael Mayer <michael@photoprism.app>
Adds env-var interpolation for the bind host, host ports (HTTP/HTTPS,
MariaDB, PostgreSQL, LDAP), bridge-network name, and PHOTOPRISM_SITE_URL
so developers can spin up parallel dev environments via .env overrides.
Templates the traefik.docker.network labels on every Traefik-routed
service to follow the network rename, fixes the photoprism-vision and
dummy-ldap router/service name collisions, and documents the available
knobs in a new .env.example template (whitelisted in .gitignore).
Signed-off-by: Michael Mayer <michael@photoprism.app>
For limited-access sessions the empty Photo placeholder seeded by
Photo.getDefaults() short-circuited fileIcon / fileTypeName to "image"
and formatTime to "Unknown", masking the Thumb model's real values.
Gate both reads on this.photo.UID so they fall through to the Thumb
(matches the existing fileInfo guard).
Move caption HTML rendering into the captions plugin (`common/captions.js`)
via `formatCaption(model)` and add `refreshCaption(slide)` so edits made in
the sidebar surface on the overlay when the sidebar collapses. The lightbox
now subscribes to `photos.updated` and patches Title / Caption on slide
models so cross-tab edits also refresh the dynamic caption.
Move the 'leave it out / multi-paragraph comments belong elsewhere'
clause ahead of the indent-examples bullet so the priority order reads
high-to-low, and point the off-source destinations at the real specs/
directory and README.md files instead of generic prose.
Move the duplicated doc-comment guidance out of go-code-style.md and
frontend-rules.md into .claude/rules/code-comments.md so both languages
share one source. Mirror the same block in AGENTS.md for Codex, which
does not load .claude/rules/. Raise the follow-up line cap from 1-2 to
up to 3 to match what compact comments actually need in practice.
PHOTOPRISM_OIDC_REDIRECT only fired for the root path; deep links under
/library/* fell through to the SPA bootstrap, which served the local
login page with a manual "Continue with OIDC" button. The deep-link
URL was also dropped on the OIDC roundtrip because $session.loginRedirect
lived only in memory, which the IdP bounce wipes.
The login page now auto-triggers OIDC on mount when the flag is on, and
$session persists the redirect target in namespaced storage under a key
(login.next) that survives auth.gohtml's session.* clear. A one-shot
login.logout flag set on signOut suppresses one auto-trigger so an
explicit logout shows the login form once.
Also adds a /logout SPA route so direct visits behave like the navigation
menu action. The new $session.signOut() snapshots the auth token, fires
the server DELETE with it (so the response is 200 and no 401 echo
re-raises the logout flag), then resets client state synchronously.
AccPass and AccKey are tagged json:"-" on the backend so they're absent
from GET responses. The base Model only tracked keys present in the
response, so getValues(true) silently dropped credential edits and the
PUT body never carried them.
Adds a generic getWriteOnly() hook on the base Model (parallel to
getDefaults()): subclasses declare write-only fields and the constructor
seeds them into __originalValues so the existing change-diff machinery
detects user edits. Service overrides it for AccPass and AccKey.
Refreshes `#:` source-path references after the sidebar relocation
from component/sidebar/ to component/lightbox/sidebar(.vue|/toolbar.vue).
No string changes.
Renames the sidebar component to reflect its only mount point: the
lightbox. Bundles file moves, name renames, template tags, imports,
CSS class renames, and acceptance + vitest selector sweeps so the
build never lands half-renamed.
- frontend/src/component/sidebar/info.vue -> lightbox/sidebar.vue
- frontend/src/component/sidebar/inline-toolbar.vue -> lightbox/sidebar/toolbar.vue
- PSidebarInfo -> PLightboxSidebar; PSidebarInlineToolbar -> PLightboxSidebarToolbar
- .p-sidebar-info -> .p-lightbox-sidebar (and the toolbar variant)
- vitest files relocated; gettext catalog regen deferred to next pass.
Camera popover (component/meta/camera/dialog.vue): wraps the inputs in
<v-form ref="form" v-model="valid">, seeds validate() in afterEnter, and
gates confirm() on $refs.form.validate() with a "Changes could not be
saved" toast on failure. Save button binds :disabled="!valid" so the
disabled state mirrors the datetime popover's :disabled="invalidDate"
pattern and the user sees the gate before clicking.
Datetime popover (component/meta/datetime/dialog.vue): setTime() now
flips invalidDate when the time field is malformed (previously
short-circuited and left the flag stale, so Confirm stayed enabled
against bad input). Time field gains :error="invalidDate" for visual
consistency with the Day/Month/Year siblings.
Both popovers rename the primary action from "Confirm" to "Save" with a
matching aria-label, aligning with the regular Edit Dialog. The
action-confirm CSS class stays for selector compatibility with the
acceptance tests.
Sidebar (component/sidebar/info.vue): formatTime() now delegates to
photo.getDateString(true) when the full Photo is loaded, so Day/Month/
Year=-1 (Unknown) values render via the model's existing fallback
(Unknown / year / month + year) instead of the padded "Jan 1, 2024"
shape. The Thumb model from viewer.Result doesn't carry Year/Month/
Day, so the fallback to the Thumb's plain TakenAtLocal format still
covers the brief pre-load window.
ISO, F Number, and Focal Length on the Edit Dialog and Camera popover
previously capped at 1048576, much wider than the backend's SetExposure
clamps (ISO and FocalLength <= 128000, FNumber <= 256). The looser caps
let the rule pass values the server then quietly rejects. Tightens each
to match the backend, adds Exposure: 64 to PhotoMaxLength, and routes
the Exposure rule through PhotoMaxLength.Exposure instead of a hardcoded
literal.
Drops the bare hide-details attribute from Country, Altitude, ISO,
Exposure, F Number, and Focal Length on both surfaces so the rule's
inline error renders below the input on overflow. Date/time inputs
(Day/Month/Year/Time autocompletes + the standalone DateTime dialog)
stay as-is: autocompletes can only emit valid items, the Time field
combines rules.time() with the :error="invalidDate" visual cue and the
form-validate save gate.
Adds per-entity MaxLength constants on model/{photo,label,subject,album}.js
mirroring the backend VARCHAR widths, and routes every inline-text input
in the Edit Dialog, Batch Edit, sidebar inline editor, Photo Labels /
People tabs, New People page, and the Label / Album / People edit dialogs
through the shared rules.text(...) factory in common/form.js. Deletes the
$config.get('clip') = 160 anti-pattern that rejected valid 200-char titles
and 1024-char rights fields, and drops the bare `hide-details` attribute
that masked the inline error slot.
Adds a $refs.form.validate() gate to the Edit Dialog and Batch Edit save
handlers, mirroring page/settings/account.vue, so an overlength field
blocks the save with $notify.error("Changes could not be saved") instead
of firing photo.update() against invalid input. For the sidebar inline
editor (no parent v-form) confirmField() now checks the field's
fieldRegistry.maxLength imperatively and keeps the editor open on
overflow; navigation arrows then route through confirmDiscardPending,
which opens the existing discard dialog with a "Discard invalid changes?"
label so the user explicitly chooses to revert or stay.
Includes focused vitest cases for the new caps, the form-validate gate
on both dialogs, the sidebar imperative check, and the new discard-dialog
overflow branch.
Hides the Labels and Albums sections in the Batch Edit dialog when
the matching deployment feature is disabled or the session lacks
the `labels:search` / `albums:search` grant, mirroring the gate
pattern already in `sidebar/info.vue`. Skips the typeahead prefetch
for any disabled section so a deployment with both features off
spends no round-trip on data the dialog never reads. Also moves the
Albums row above Labels so the order matches the lightbox sidebar.
Drops shouldShowEditButton() from the <p-face-marker-overlay> v-if so
non-editable users (Plus / Pro viewer role, anyone reaching the eye
toggle in the sidebar) actually see marker rectangles AND the
in-overlay Back button when they activate display mode. The previous
gate required edit permission, so viewers entered face-marker mode
(chrome hidden), saw nothing, and had no way to exit. Edit gestures
stay gated separately by the overlay's isEditMode flag and the
lightbox-side onCreateFaceMarker / onRemoveFaceMarker /
toggleFaceMarkerEdit handlers that re-check shouldShowEditButton().
Pre-existing bug; surfaced while testing the recent ACL-driven
sidebar refactor (#4966).
Replaces the static SidebarRestrictedRoles array ("guest", "visitor",
"contributor") and the User/Session isSidebarRestricted helpers with
per-resource $config.allow(resource, perm) checks. The frontend now
mirrors the backend's authority (internal/auth/acl/rules.go) instead
of duplicating policy: each sidebar section reads the same ACL grant
the server uses to redact the JSON response. Drift fix on the way —
"contributor" wasn't a CE role and the static list silently allowed
the "viewer" role full sidebar access without any spec saying so.
New per-section computeds in sidebar/info.vue:
- canViewLibrary -> $config.allow("photos", "access_library")
Gates camera, lens, file name, and the rights / notes cluster
(photographer-EXIF privacy fields).
- canViewPeople -> $config.allow("people", "search")
- canViewLabels -> $config.allow("labels", "search")
- canViewAlbums -> $config.allow("albums", "search")
- canViewPlaces -> $config.allow("places", "view")
lightbox.vue's fetchPhoto / preloadNextPhoto guards switch from
$session.isSidebarRestricted() to $config.deny("photos",
"access_library") so sessions without library access still skip
the extra GET /photos/:uid calls.
PhotoSwipe Dynamic Caption overlay can now be hidden via Ctrl+H so
the photo takes the full canvas without leaving the lightbox or
opening the sidebar. State persists to localStorage as
`lightbox.caption` (mirrors the existing `lightbox.info` flag), so
the user's choice survives slide nav, lightbox close/reopen, and
page reload. The toggle gates the forked dynamic-caption plugin's
panAreaSize adjustment via a new `enabled` option and routes the
layout recompute through this.resize(true) after $nextTick (the
.hide-caption class binding is reactive), so the photo actually
reclaims the caption's reserved vertical space when not width-
bound by aspect ratio. Shortcut sits inside the existing input-
focus gate, is inert in face-marker mode, and is a no-op when the
sidebar is open (captions render in the sidebar there). Also adds
mouse-driven Show/Hide Caption entries to the lightbox dropdown
menu, mirroring the Archive/Restore mutually-exclusive pattern.
Picks up the "Edit Faces" / "Remove Name" → "Edit" / "Unassign"
renames from the previous commit. "Edit" was already broadly
translated; "Unassign" is freshly active and needs new translations
across locales. "Remove Name" demoted to obsolete (#~) with prior
translations preserved.
Renames the People-section toggle from "Edit Faces" to "Edit" — the
mdi-pencil icon plus surrounding section context already convey the
scope, and the shorter label aligns with the rest of the inline-toolbar
pencil affordances. Renames the marker-name eject tooltip from "Remove
Name" to "Unassign", which more accurately describes the action (the
subject assignment is removed, not the name as text). Same string now
covers both edit-dialog and sidebar surfaces. Also reorders a couple
of v-btn attributes for consistency.
Notes uses `icon: null` so Vuetify's default `'end center'` tooltip
location anchors against an empty prepend slot, putting the tooltip
in a visually inconsistent place vs sibling rows. Gate the tooltip
on the row having an icon so Notes (and any future no-icon row in
detailsFields) skips the tooltip entirely — matches Title and
Caption above which never had one. The "Notes" / placeholder text
is already visible inline as the add-prompt, so no information is
lost.
Hovering a person row in the lightbox sidebar's People section now
sets a thicker purple stroke + glow on the matching face-marker rect
on the image, making the visual link between the row and the box
unambiguous in both display and edit modes. Implementation routes
through a new hoveredMarkerUid field on the $faceMarkers singleton
that the lightbox forwards as a prop to the overlay. Reverse
direction (rect hover → sidebar row) isn't wired since rects carry
pointer-events: none — recorded for future work in the singleton +
CSS comments. Also drops two redundant text-align: start declarations
on .meta-title / .meta-caption (defaults already match).
Adds an input/textarea/contenteditable focus gate at the top of the
lightbox onShortCut handler so Ctrl+A select-all, Ctrl+C copy, etc.
no longer trigger global lightbox actions when a sidebar inline
editor or edit-dialog field has focus. Migrates the Archive/Restore
binding from KeyA to KeyX (Gmail/Outlook-style mnemonic) so the
canonical select-all chord stays free for text editing in addition
to the focus gate. Updates the matching face-marker disabled-shortcut
list and acceptance + vitest test cases.
Unifies Subject / Artist / Copyright / License / Keywords / Notes
into a single icon-prepended row layout (Option A from the proposal),
adds per-field maxLength sourced from new PhotoMaxLength constants
mirroring the backend VARCHAR caps, fixes a pre-existing textarea
auto-grow regression caused by a static min-height override in
lightbox.css, wires Enter-to-commit on the single-line fields via a
commitOnEnter registry flag, and adds an Undo affordance + new
hideEditUndo / hideEditSave toggles for A/B testing the inline
toolbar chrome.
common/event.js now exports six action-verb constants (ACTION_CREATED,
ACTION_UPDATED, ACTION_DELETED, ACTION_ARCHIVED, ACTION_RESTORED,
ACTION_EDITED) and composes ENTITY_MUTATIONS from them so there is one
source of truth. Every onUpdate(ev, data) switch in the frontend now
references the symbols instead of the literal strings — eight cache /
page-level handlers across albums, labels, library/browse, photos,
people, the album-photos view, and the photo edit dialog.
The motivation is navigation: grep / IDE find-references on
"updated" or "created" is overwhelmed by Vue lifecycle hooks, Date
methods, prose in comments, and unrelated occurrences of the same
English word. ACTION_UPDATED finds only the event-handling sites.
The count.* subtype switch in common/config.js stays on string
literals because those are count category names, not entity-mutation
verbs.
Replaces eight per-component titleRule / textRule / markerNameRule
arrow functions across the album / label / people edit dialogs, the
photo edit details panel, and the sidebar inline editors with the
existing rules.text(required, min, max, label) factory in
common/form.js. The factory is already null-, undefined-, and
object-safe via its typeof v !== "string" short-circuit, so this
removes a class of latent v.length crashes and produces label-
specific localized error messages ("Title is too long",
"Name is too long", etc.) instead of one generic string.
Also deletes six dead-code titleRule definitions in album/toolbar,
page/albums, page/labels, page/library/browse, and the two People
pages — defined in data() but never referenced from the template.
While there, harden two inconsistencies in form.js:
- isTime now short-circuits on non-string / empty inputs to match
the other is* helpers (isEmail, isUrl, isLat, etc.).
- rules.time(true) validity rule short-circuits on empty input so
"Invalid time" isn't flashed alongside "This field is required"
— matches the email(true) / url(true) pattern.
The v-combobox initializes newLabelModel = null and validates :rules on
mount, so nameRule must tolerate null, strings (free-text entry), and
item objects (return-object). Pre-fix the rule did v.length and threw
"Cannot read properties of null (reading 'length')" on every Edit
Dialog open. Six vitest cases pin the contract.
Backend emits a new lightweight photos.edited event from
batch_photos_edit.go via the generic event.EntitiesEdited helper, so the
frontend can drop stale Photo._cache LRU entries after a batch save
without the per-UID payload storm that PublishPhotoEvent would
otherwise produce. The frontend's cache layers route through a shared
subscribeEntityActions helper that subscribes once per namespace and
filters by action verb against ENTITY_MUTATIONS, so any future entity-
mutation verb joins via one edit on each side. Page-level wildcard
subscribers learn a new "edited" case so the cards list refreshes on
return-to-view.
Editable users now see ONLY the pencil / pencil-off "Edit Faces"
toggle (.meta-faces-edit); non-editable users see ONLY the eye /
eye-off "Show face markers" toggle (.meta-markers-toggle, gated on
people.length > 0). Draw mode is a strict superset of display mode
for editable users — both freeze playback, hide chrome, and gate
the conflict-only keyboard shortcuts — so a separate display toggle
adds no value and was confusing in the two-button layout.
toggleFaceMarkerDraw's exit path now lands on `null` instead of
stepping down to FaceMarkerDisplay (the historical ✓ Done semantic
needed the eye toggle as a second affordance to land on display
mode — gone with the single-button-per-role model, so the pencil
would get stuck "on" without this change).
toggleFaceMarkerMode gate relaxed from shouldShowEditButton() to
featPeople: non-editable users can now toggle display mode via the
eye button. Sidebar onToggleFaceMarkerMode similarly dropped its
isEditable guard. The shortcut-gate test is parametrized over both
display and draw mode to pin that the inert key set applies in
both.
Both helpers in common/view.js had zero source consumers after the
preventNavigationTouchEvent rewrite. isInputElement was historically
used to exempt button taps from the unconditional touchmove
preventDefault (name was misleading — it only matched HTMLButtonElement,
not real inputs); the rewrite inlines a broader closest('button, input,
textarea, select, a[href], [role=button]') check instead.
isMediaElement was historically used to also fire preventDefault on
touchstart of canvas-area img/video/canvas targets (likely a defense
against iOS default touch behavior on photos, but
.pswp__img { -webkit-touch-callout: default; } in lightbox.css
re-enables the iOS save-image callout anyway); the user-clarified
handler intent is edge-only, so this branch is no longer needed.
Mouse-wheel zoom no longer works in face-marker draw mode because the
overlay's pointer-events: auto (needed for drag-to-draw) swallows wheel
events that PhotoSwipe sits in a sibling subtree from. Add a @wheel
listener on the overlay root that re-dispatches a synthetic WheelEvent
on pswp.element so zoom-to-cursor works identically to display mode.
Rewrite preventNavigationTouchEvent to a 30 px edge-band-scoped
suppressor. The previous implementation preventDefault()ed every
non-button touchmove anywhere on the page while the lightbox was open,
which made the sidebar / dialog content / menus unscrollable on iPad.
The handler now fires only when the touch sits inside the leftmost,
rightmost, or topmost 30 px band of the viewport — and even then it
exempts taps on interactive widgets (button, input, textarea, select,
a[href], [role=button], including taps that land on a nested glyph
inside a button) so the overlay's top-left Back button and the
sidebar's right-edge action buttons stay tap-reliable.
Add 21 regression cases for preventNavigationTouchEvent in
common/view.test.js with a minimal TouchEvent polyfill — jsdom doesn't
ship one and the handler's first guard is `ev instanceof TouchEvent`,
so without the polyfill the function silently no-ops in tests.
While face-marker mode is active, suppress shortcuts that open
chrome we hide (Period menu+select, KeyL menu+like), stack a
competing modal (KeyE Edit Dialog), fire silent destructive
mutations (KeyA archive), or contradict the entry-only-pause /
overlay-stays-mounted contracts (KeyS slideshow, Space play,
ArrowLeft / ArrowRight slide-nav). Escape, Tab, KeyI, KeyD, KeyF,
KeyM stay enabled — Escape and KeyI already exit face-marker mode
in lockstep; the others are non-destructive display-only.
Implemented as a single isShortcutDisabledInFaceMarkerMode
predicate consulted at the head of onShortCut and onKeyDown.
The per-row mdi-close button inside the marker combobox read as
"clear input" but actually rejected the face marker on the backend
without confirmation — a real hazard. Retire the button entirely:
sidebar #append-inner now only renders the mdi-eject for named
markers. Add a click-to-remove flow on the face-marker overlay
instead: in edit mode, clicking an existing unnamed marker rect
opens an inline confirm pill (mdi-delete ✓ / mdi-close ✕) anchored
to the marker. Targeted rect gains a red --removing outline. ✓
emits the new `remove` event the lightbox routes to
onRemoveFaceMarker; ✕ dismisses. Escape clears a pending remove
first; leaving draw mode clears it too. Named markers stay
non-removable here — eject first.
Rename the sidebar +/Done toggle to a pencil/pencil-off Edit Faces
toggle (CSS class meta-marker-add → meta-faces-edit) to signal the
toggle now covers both add and remove. Names of named markers stay
visible in edit mode so the user can tell named (untouchable) from
unnamed (clickable) markers at a glance.
While face-marker mode is active the standard PhotoSwipe chrome over
the canvas (top-bar buttons including close, prev/next arrows,
caption, counter, custom video controls) is hidden via CSS — in draw
mode the overlay's pointer capture made them unreachable anyway, and
in display mode they would fire on a paused-video / disabled state.
The face-marker overlay grows its own circular Back button
(mdi-arrow-left, RTL-aware, top-left in LTR / top-right in RTL) with
its own pointer-events so it works in both modes. Click emits cancel,
which the lightbox already binds to exitFaceMarkerMode — the single
explicit exit affordance from face-marker mode. Sidebar chrome stays
reachable.
Move faceMarkerMode, markersBusy, and pendingNameMarkerUid out of the
lightbox data() into common/face-markers.js — a reactive singleton
imported by both lightbox.vue and sidebar/info.vue, removing the
view.faceMarkerMode bridge. The sidebar reads the singleton directly
and continues to emit transition requests; the lightbox stays the
policy owner and writes through the singleton's setters. Replace the
local faceMarkers data array with a markers computed that returns
photo.getMarkers(true); reloadFaceMarkers() is gone and the overlay
re-renders via Vue reactivity on file.Markers (syncMarkerInFile stays
to bridge API-result Marker instances back into the raw entry).
Replace .pswp--adding-marker imperative class toggle with declarative
:class="{'face-marker-mode': faceMarkerMode}" on .p-lightbox__content;
playback (video + slideshow) now pauses on entry into either display
or draw mode and is NOT resumed on exit. Rename toggleMarkersVisible
to toggleFaceMarkerMode and toggleAddingMarker to toggleFaceMarkerDraw
across the lightbox, sidebar emits/handlers, and tests. Switch every
:menu-icon="false" to :menu-icon="null" (typed IconValue form) to
silence Vuetify prop warnings.
The MapLibre default marker is rendered as a <button>, so the
browser styled it with `cursor: pointer` even though no click
handler was attached — a phantom affordance. As an interim
improvement, wire the marker to `openLocationDialog` for editable
sessions and remove the pointer cursor for everyone else.
`<p-map>` (`component/map.vue`) gained a `markerClickable` prop:
when true, it attaches a click listener on the marker's DOM
element that emits `marker-clicked` with the marker's
`{ lat, lng }` (and stops propagation), and adds an
`is-marker-clickable` class to the `.p-map` root for cursor
scoping. The marker stays keyboard-activatable via the existing
<button> semantics.
`places.css` defaults `.p-map .maplibregl-marker { cursor: default }`
and restores `pointer` only under `.p-map.is-marker-clickable`, so
read-only sessions no longer see a misleading affordance.
In the sidebar (`component/sidebar/info.vue`), the embedded map
passes `:marker-clickable="isEditable"` and binds
`@marker-clicked="openLocationDialog"`. `openLocationDialog`
already gates internally on `isEditable && featPlaces`, so the
event side is defense-in-depth.
Every editable sidebar row is now itself the primary click target.
Click the row -> opens the matching dialog (Date / Camera / Lens /
Location) or enters the matching inline edit (Title / Caption /
Subject / Artist / Copyright / License / Keywords / Notes). The
pencil icons remain available as a secondary affordance but are
hidden by default via a new `hideEditPencils: true` data flag plus
a `.hide-edit-pencils` parent rule in lightbox.css; save and undo
buttons in the chip-section toolbars stay reachable because they
carry their own class names.
Two helper methods route the dispatch: `onTextRowClick(field, value)`
(edit mode -> startEditing, read-only -> $util.copyText) and
`onLocationRowClick()` (editable -> open dialog, read-only +
coords -> copyLatLng, read-only + placeName -> copyText). The new
`open<X>Dialog` methods gate `isEditable` (and `featPlaces` for
location) inside the method body, so the template never inlines
the gate.
File row consolidated into a single two-line v-list-item
(`meta-file`) with `prepend-icon="fileIcon"` (new computed mapping
media.Type to mdi glyphs); title is fileInfo, subtitle is fileName.
Click-to-copy is the only affordance — no pencil, no dialog.
`fileName` now returns `null` (not "") for restricted sessions and
the no-photo state so Vuetify's subtitle gate (`props.subtitle !=
null`) skips rendering an empty subtitle element. Historical
`metadata__file-info` / `metadata__file-name` classes retired in
favor of the consistent `meta-file`.
Location row consolidated similarly via new `locationTitle`,
`locationSubtitle`, `locationRowVisible`, `locationRowClickable`,
`coordinatesLine` computeds. New `getLatLngShort()` on the Thumb
model uses 4-digit precision and an en-space separator; `getLatLng()`
also bumped from regular space to en-space for consistency.
CSS polish: places.css sidebar map switches from `min-height: 259px`
+ `aspect-ratio: 1` to `height: 200px`, freeing vertical space;
one v-divider between camera/lens and location removed.
Tests + acceptance pins updated to the new merged-row class and
the `null` semantic.
Sidebar inputs drop variant="plain" to pick up the project-wide
solo-filled default. Marker, Labels, and Albums comboboxes use
:menu-icon="false" unconditionally; the Edit Dialog People tab,
New Person page, and location-search autocomplete adopt the same
chevron-off convention. Labels / Albums placeholders standardised
on "Select or create labels/albums".
Lightbox theme: surface raised to #151515, surface-bright to #171717
so it stays brighter than surface, highlight softened to #383838;
new fill-opacity, theme-overlay-multiplier, and *-emphasis-opacity
variables; color and variable comments rewritten and trimmed.
lightbox.css: migrate inline-input geometry from per-rule min-height
and padding overrides to --v-input-control-height + --v-field-padding-*
custom properties; drop the redundant .meta-inline-menu .v-list
secondary-light lift; reword the scrollbar TODO as a deliberate
follow-up.
The marker-chevron test was renamed and rewritten to assert
unconditional chevron absence on every marker row.
Replaces lightbox.markersVisible + lightbox.addingMarker with a single faceMarkerMode field (null | FaceMarkerDisplay | FaceMarkerDraw). The state-enum constants live in options/face-marker.js so the lightbox, the face-marker overlay's mode prop, and the sidebar's derived markersVisible / addingMarker computeds all reference the same source of truth — no string literals to drift.
The previous two booleans encoded one invalid combination (draw mode without markers visible) that the code carefully avoided but never enforced; the enum makes it unrepresentable. The watcher on faceMarkerMode owns playback pause / resume, the toggleMarkersVisible and toggleAddingMarker exit paths both route through a single exitFaceMarkerMode helper (which clears the local faceMarkers array too), and hideInfo now fully exits face-marker UI when active so a closed sidebar can't strand the overlay over a resumed video.
Also drops the one-line cancelAddingMarker delegate (F2-2): the overlay's removed @cancel emit was the only consumer; the template @cancel binding and onEscapeKey now point at exitFaceMarkerMode directly.
Exiting Add Face mode (✓ Done or Escape) now also clears markersVisible / faceMarkers so the resumed video is no longer painted with stale boxes that anchor to the JPG cover. Escape from display-only mode (eye toggle without Add Face) follows the same priority chain: hide the overlay first, close the lightbox only when no face-marker UI is active. Routed via a shared exitFaceMarkerMode() helper called from both toggleAddingMarker's exit path and cancelAddingMarker.
Two related overlay refinements for video / Live / Animated slides where the JPG cover is CSS-stretched to the slide container box:
1. updateBounds now insets the bounds rect by the letterbox / pillarbox gap derived from the image's naturalWidth/naturalHeight. The <img class=pswp__image> takes the parent's height: 100% via CSS, but the inscribed image content honours object-fit: contain — so getBoundingClientRect returns the BOX, not the displayed image. The previous behaviour rendered face markers across the black bars (faces looked TALL). An image load listener triggers re-computation once natural dimensions are available.
2. The window-level keydown listener was removed in favour of the documented onShortCut + @keydown.esc.exact pattern (see frontend/src/common/README.md). The overlay now exposes a public handleEscape() method that the lightbox's onEscapeKey calls via ref: when an in-flight draw / move / resize or unconfirmed pending rect exists the overlay consumes the key; otherwise the lightbox decides between exiting draw mode (cancelAddingMarker) and closing the lightbox (close). Previously Escape during draw mode closed the lightbox via the v-dialog handler.
A paused HTMLVideoElement holds an arbitrary frame, not the still image the face markers were detected on, so the rectangles never align with anything. Add a pswp--adding-marker class on the PhotoSwipe root while addingMarker is true; existing .pswp__media CSS rules already flip .pswp__image visible and .pswp__video / .pswp__play hidden — extend them to honour the new class for video, Live, and Animated slides. Trigger a face-marker-overlay scheduleUpdate() after the swap so bounds re-anchor on the now-visible <img>.
The sidebar's fileName computed used to surface primaryFile().Name, which for video / Live / Animated photos is the generated JPEG cover and renders as 'foo.mp4.jpg'. Route through Photo.originalFile() first so the user-facing path matches the cards view (getOriginalName()).
Auto-playing video, Live Photo, and Animated all kept running while addingMarker was true, defeating accurate face-region selection. A watch on addingMarker now routes every flip through pausePlaybackForAddingMarker / restorePlaybackAfterAddingMarker, capturing the playing state on entry and only resuming on exit if the user had been actively playing. hideInfo also clears addingMarker so closing the sidebar mid-draw tears down the overlay and resumes playback.
Locale-aware findKnownPerson (no en lock-in); sort knownPeople via locale-aware localeCompare. Add per-draft editing flag so syncMarkerDrafts leaves typed text alone during concurrent WS updates. Gate confirmMarkerName on markersBusy, marker.Invalid, and a 200ms destructive-action window stamped by onRemoveMarker / onEjectMarker. Store markerUid (not the transient Marker instance) in addNameDialog; re-derive the live marker via findMarker(uid) at commit. Scope cancelMarkerName blur to the marker row's own input.
The previous fix (a81082a26) kept @blur="onInputBlur" with a
relatedTarget guard so ArrowDown navigation wouldn't commit typed
prefixes. In practice the blur hook had no reachable trigger left
once that guard was in place — neither Tab nor click-outside
actually fired the commit. Drop the @blur binding and the
onInputBlur helper entirely; commits now happen only on Enter
(`onEnter`) or when v-combobox emits a real item-object selection
(`onComboboxChange`). Pending typed text is discarded when focus
leaves the field, which matches how the sidebar combobox already
behaves. Test pin rewritten to assert both halves of the contract:
ArrowDown does not commit, Enter still does.
The batch-edit dialog routed user-typed label adds through
entity.FindLabel(title, true) and only fell back to
FirstOrCreateLabel when FindLabel reported not-found. FindLabel's
slug fallback is not homophone-safe: when the user typed `吻`
(kiss, pinyin `wěn`) and only `问` (ask, same pinyin slug `wen`)
existed, FindLabel returned `问` and the new chip silently
disappeared on Save because the photo-label join was created
against the wrong label.
ensureLabelUID and ApplyLabels now call FirstOrCreateLabel
directly. That path goes through findLabelByExactName, which
applies acceptLabelSlugMatch: a slug-only collision against a
first-created homophone (LabelSlug == CustomSlug) is rejected, so
FirstOrCreateLabel falls through to creating a new row with a
deterministic disambiguating slug (e.g. `wen-bf535866`). FindLabel
itself is intentionally left unchanged — its callers (vision label
normalizer, slug-string lookups in tests) rely on the slug
fallback for non-homophone scenarios.
Vuetify's v-combobox shifts DOM focus from the input to the first
v-list-item in the dropdown menu when the user presses ArrowDown.
The chip-selector's @blur handler treated that focus shift as
"user is leaving the field — commit pending text," which silently
turned typed prefixes (e.g. `ca`) into brand-new chips before the
user could highlight `Camping` and press Enter.
The blur handler now inspects relatedTarget and skips the commit
when focus moves into a v-list-item or any descendant of a
v-overlay-container / v-list / v-menu. Tabbing or clicking to a
sibling field still commits as before. The chip-selector backs the
Labels and Albums inputs in the batch-edit dialog (and the file
dialog), so the regression was visible in both surfaces.
Earlier in the chip-removal refactor onChipActivate started toggling
pending removal whenever isEditable, which made the entire chip area
act as a remove button instead of the long-standing link gesture.
Click + Enter on a chip now always route through navigateToLabel /
navigateToAlbum (in both editable and read-only contexts); pending
removal lives on the × icon's own @click handler and the keyboard
Delete / Backspace path via onChipDelete. The × icon gains a `Remove`
tooltip and a `meta-chip__remove` class for future styling hooks.
Existing "toggles removal on click" tests rewritten as "navigates
even when editable" pins so the regression cannot return silently.
Adding a brand-new label or album from any consumer left the
typeahead cache stale until something else evicted it. The cache
already listened for labels.updated / labels.deleted / albums.updated
/ albums.deleted / config.updated but not the *.created channels
that the entity layer fires from FirstOrCreateLabel and Album.Save.
Result: typing the just-added label or album on a subsequent photo
returned no autocomplete match, even though the row existed
server-side. Subscribing to labels.created and albums.created
evicts the relevant slot so the next read repopulates.
Refreshes the module-level comment to reflect what each backend
event actually publishes (entity-layer EntitiesCreated for create,
PublishLabelEvent/PublishAlbumEvent for update, EntitiesDeleted for
batch delete). Adds matching Vitest cases mirroring the existing
labels.updated / albums.updated tests.
Soft-removed labels and albums now vanish from the layout (filtered
out by new visibleLabels / visibleAlbums computeds) instead of
turning red with an undo-icon swap. The chip-row wrapper disappears
once every chip in the section is queued for removal, so no empty
box hovers above the combobox.
A new Undo icon in the section toolbar (next to the existing
Apply check) restores all pending removals in one click, wired via
the shared inline-toolbar's new canUndo prop. Pending removals
auto-commit silently on slide navigation and lightbox close, just
like inline-text auto-commit on blur — the discard-pending dialog
only fires for typed combobox text, marker drafts, and the open
Add-name confirmation. Adds Vitest coverage for the new
visibleLabels / visibleAlbums computeds, undoChipRemovals,
auto-commit in confirmDiscardPending, and the inline-toolbar's
canUndo / undo emit contract.
Rename PMetaDateTimeDialog to PMetaDatetimeDialog so Vue's
PascalCase-to-kebab mapping produces <p-meta-datetime-dialog>,
matching the .p-meta-datetime-dialog CSS scope class. Pre-rename
the template tag was <p-meta-date-time-dialog> (three words) while
the CSS class compressed to "datetime"; aligning the component
name on the compact form removes that inconsistency for good.
Rename PCameraDialog, PDateTimeDialog, PLocationDialog, and
PLocationInput to PMetaCameraDialog, PMetaDateTimeDialog,
PMetaLocationDialog, and PMetaLocationInput so the symbol mirrors
the new meta/<domain>/ directory tree introduced in 177771c5c.
CSS scope classes, template tags, Vitest stubs, and TestCafe
page-model selectors update in lockstep so the kebab-case CSS
class and the PascalCase component name stay derived from each
other (.p-meta-camera-dialog etc.).
Move the three metadata-adjustment dialogs out of sidebar/ and
location/ into a shared meta/<domain>/ tree so they read as
reusable metadata editors rather than sidebar-internal helpers
(the location dialog was already imported by the Edit Dialog and
batch-edit). Matching Vitest files relocate alongside their
sources. Component names (PCameraDialog, PDateTimeDialog,
PLocationDialog, PLocationInput) are preserved.
Rename the dialog titles to "Adjust Date & Time" and "Adjust Camera
Info" to mirror the existing "Adjust Location" wording, and align the
layout with the project's canonical dialog convention: v-card-text
carries only the shared "dense" class, action buttons live in a
sibling v-card-actions, and the local breathing room sits on v-row
via "py-2" so the v-card-text class stays uniform across dialogs.
Switch the sidebar album input to v-combobox so free-text Enter creates a
new album (mirroring labels) instead of v-autocomplete auto-committing a
substring-filtered match. Drop the fuzzy startsWith/includes fallback in
onAlbumEnter so typing `test` no longer silently merges into an existing
`LRUTEST-ALBUM-…`. Map album options to plain {Title, UID} objects and
make onAlbumSelected a silent no-op on transient non-object updates so
the combobox stays usable while typing.
Restore the lightbox theme's surface-variant to a contrasting light grey
(#bdbdbd) — the previous #242628 blended into the `selected` row bg and
made Vuetify-active items invisible because VCombobox defaults bind
`text-surface-variant` onto the matched row. Invert on-surface-variant
to keep the pair sane and add a scoped tooltip CSS rule so lightbox
tooltips render dark with light text instead of inheriting the now-light
surface-variant block.
- Rework CliFlag.Default() to read the documented default through
cli.DocGenerationFlag.GetDefaultText() instead of GetValue(); urfave
snapshots the source-code default in Apply() before reading the
environment, so reports stay stable across deployments and no longer
echo operator-supplied env values for runtime-mutated string flags.
- Add a Secret annotation on CliFlag for sensitive options. When set,
Default() collapses to DocDefault (or "") so secrets like
admin-password, oidc-secret, database-password, auth-secret,
node-client-secret, join-token, download-token, preview-token, and
vision-key never appear in `photoprism show config-options` or
downstream consumers of the same report data.
- Convert index-workers from IntFlag to StringFlag with the new
IndexWorkersAuto = "auto" sentinel, matching the convention already
used by thumb-color, thumb-filter, and database-driver. Options.
IndexWorkers becomes a string; Config.IndexWorkers() parses it once
and keeps the existing CPU/SQLite caps. yaml.v2 silently coerces the
legacy `IndexWorkers: 4` form into the string field, so existing
operator config files load unchanged. Drops the dynamic
cpuid.CPU.PhysicalCores/2 default and the previous DocDefault hack.
Regenerated swagger.json picks up the new string type.
- Harden the Markdown report renderer (pkg/txt/report/markdown.go) by
backslash-escaping `<` and `>` in row and header cells alongside the
existing `|` and `* * *` escapes. Prevents arbitrary HTML from being
emitted into downstream HTML pipelines that render the table source.
- Fix two pre-existing lint findings surfaced by the newer
golangci-lint: misspell `marshalling` -> `marshaling` and replace a
field-by-field struct literal with a same-shape type conversion
(staticcheck S1016).
- Add coverage for every new code path: Default() Secret/env-override
regression, GetDefaultText/strconv.Unquote round-trip, parseIndexWorkers
branch matrix, ApplyCliContext index-workers string binding, the YAML
round-trip across `auto`/quoted/bare-int/empty forms, and the Markdown
angle-bracket escape.
availableLabelOptions and availableAlbumOptions become computeds that
hide anything already in labelItems / albumItems (matched by
$util.normalizeTitle so case and punctuation variants collapse) and
sort the survivors via locale-aware localeCompare. Raw cache results
live on cachedLabelOptions / cachedAlbumOptions so resolveLabelFromText
can still find canonical matches for items already in the chip list.
Brings the Batch Edit dialog in line with the Edit Dialog labels tab
and the lightbox sidebar — every label/album typeahead now filters
already-assigned items out of the dropdown and sorts alphabetically.
The shared typeahead cache returns the cap-bounded result in whatever
order the backend's search?order=name produced, which in practice is
not reliably alphabetical (and would mis-sort Hebrew or Cyrillic
strings under naive byte-order anyway). loadChipOptions now applies a
locale-aware localeCompare with sensitivity:base + numeric:true after
the cache resolves, so the dropdown reads naturally in any language.
Two related dedup fixes for the Edit Dialog labels combobox:
addLabel now bails out without calling the backend when the typed
name resolves (via $util.normalizeTitle) to a label already on the
photo. The backend treats a re-add as an update and was emitting
"Label updated" + "added <name>" toast pairs whenever a user picked
an existing chip from the dropdown.
labelOptions becomes a computed that filters the cached label list by
the same normalized-name comparison and sorts the survivors via
locale-aware localeCompare. The dropdown now only surfaces actionable
suggestions, in alphabetical order — assigned labels disappear from
the list reactively the moment view.model.Labels updates.
Anchors the v-combobox menu at "bottom start" with minWidth:0 so the
dropdown's start edge tracks the input's start edge in both LTR and
RTL — Vuetify's default "bottom" + wider min-width otherwise pushes
the menu leftward into the photo-thumbnail column under the table.
Also adds density="compact" on the combobox itself to match the
lightbox sidebar's typography. Verified in browser at sub-pixel
alignment for English (delta 0.16 px) and Hebrew (delta 0.16 px).
Adds menu-icon="" to suppress the v-combobox dropdown chevron in
the Edit Dialog labels tab — the comfortable-density row positions
the chevron visibly below the input baseline, since the auto-open on
focus is the discovery affordance instead. Wires v-model:menu plus a
suppressMenuOpen debounce so committing a selection no longer re-pops
the dropdown via Vuetify's search-changed watcher when the bound
model is cleared. Mirrors the chip-selector pattern.
Replaces the bare <v-text-field> for new-label entry with a
<v-combobox> mirroring the sidebar pattern. The combobox sources its
suggestions from the shared typeahead cache and applies the same
$util.normalizeTitle canonical-match dedup as the sidebar's
addLabelImmediate (typing 'Hello Cat' resolves to an existing
'hello-cat' label instead of creating a duplicate). autofocus is
explicitly omitted because v-combobox auto-opens its menu on focus
and would lay out the dropdown before the tab geometry settles.
Replaces the per-mount Promise.all([Album.search, Label.search]) in
fetchAvailableOptions with typeaheadCache.getAlbums / getLabels.
Opening the batch-edit dialog while the lightbox sidebar is also
editable now costs zero extra round-trips, and a label or album
created elsewhere shows up next time the dialog opens via the cache's
WS-driven invalidation.
Drops the pencil-to-edit gesture for the lightbox info sidebar's chip
sections. The combobox/autocomplete row now stays mounted whenever the
section is editable, chip × icons are visible whenever isEditable, and
the toolbar ✓ only appears when there are pending REMOVALS. Per-field
input/search/key/options state lives under chipState.<field> so the
two comboboxes don't share scratch refs anymore. cancelEditing is
scoped to inline-text rollback; chip state clears via resetInlineEdits
on discard-pending. The sidebar consumes typeahead suggestions through
the new typeahead-cache module and reloads on combobox @focus to pick
up post-eviction freshness.
Adds frontend/src/common/typeahead-cache.js as a module-scope cache
that dedupes Label.search and Album.search across the sidebar info
panel, the batch-edit dialog, and the edit-dialog labels tab. WS
events (labels.updated/deleted, albums.updated/deleted, config.updated
for album deletes) evict the cache; concurrent callers share the same
in-flight promise, and session.logout clears both lists. Cap warning
behavior moves out of the sidebar into the cache implementation.
Sidebar inline dropdowns (Add label, Add to album, marker name
combobox) now structurally match every other Vuetify v-select /
v-autocomplete menu in the app:
- Drops the custom 1px-solid border + opaque .v-overlay__content
background that no other menu draws. Only the inner .v-list is
given a background; the standard MD elevation shadow alone draws
the edge.
- Lifts the inner .v-list to secondary-light (#1e1e1e) so it sits
visibly above the combobox input's surface-bright (#141417) bg
instead of merging into it.
- Forwards { density: "compact" } via :list-props on each
combobox/autocomplete (the documented Vuetify API for sizing the
menu items, separate from the input field's own density prop).
Title font drops to 0.8125rem so the menu typography matches the
sidebar's other inline-edit text.
Polishes related chip styling:
- The chip :focus-visible ring now uses rgba(--v-theme-on-surface,
0.5) instead of bright --v-theme-primary (#ebebeb), so the ring
is clearly more prominent than the chip's 12%-alpha resting
border without reading as a near-white halo on the dark sidebar
and without reusing a notification token.
- Removes the unreachable .meta-chip--pending-add rule (additions
go through the instant-save path now and never enter chipState).
The original lightbox theme was sized for a basic image viewer with
no editable controls. The info sidebar now embeds dialogs, inline
editors, autocomplete dropdowns, and chip selectors — surfaces that
bind theme tokens which were previously invisible. Three values
were too bright on the near-black lightbox surface:
- highlight: #9c9c9c -> #3d3f40 (PConfirmDialog confirm button —
was a near-white block, now a credible dark-grey button)
- selected: #9c9c9c -> #3d3f40 (Vuetify-internal selection
surfaces: active list item, autocomplete-active row, and the
on-selected fallback in views.css — same fix as highlight)
- hover-opacity: 0.019 -> 0.06,
focus-opacity: 0.022 -> 0.08
(lightbox was inheriting the dark-base defaults, which over a
near-white overlay color left dropdown hover and keyboard-focus
states almost invisible)
Scoped to the lightbox theme — every other theme keeps its current
highlight/selected values. The lightbox primary, info, and on-
surface tokens were left alone since they show up in many places
(focus rings, button text, captions) and would cascade unexpectedly.
Drops the icon="" / confirm-color="info" overrides on the discard-
unsaved-changes dialog so it inherits the same icon-left-of-title
layout and bg-highlight confirm button that every other call site
of <p-confirm-dialog> uses (see album/toolbar.vue, photo/toolbar
.vue, settings/apps.vue, etc.). Picks mdi-alert-circle-outline for
the icon — neutral warning, fits the "you may lose unsaved input"
question without implying permanent deletion.
Replaces the staged "type -> green pending chip -> click toolbar
check" flow for label and album additions with an instant-save
path. Removals stay batched under the toolbar check icon so users
can still toggle a chip back into place without an Undo.
- onLabelEnter / onLabelSelected fire Photo.addLabel(name) right
away. The model's setValues(r.data) chain repopulates this.photo
.Labels with the backend-canonical name, so the chip appears as
a real meta-chip--primary with no transient pending-add state.
- onAlbumSelected fires Photo.addToAlbum(albumUID) right away.
onAlbumEnter for brand-new titles still runs Album.save() first
and chains addToAlbum once a UID exists.
- onAlbumEnter now tries a normalized exact match against
albumOptions (via $util.normalizeTitle) before falling back to
the existing fuzzy startsWith / includes lookup, so typed
variants like "hello-cat" resolve to an existing "Hello Cat"
album instead of silently creating a near-duplicate.
- addLabelImmediate cross-checks labelOptions and uses the
canonical existing-label name when normalized-equal, so typed
"Hello Cat" reuses an existing "hello-cat" label and the user
sees the saved name immediately.
- startChipEditing bumps the typeahead cap from 1000 to 5000 and
emits a console.warn when the response equals the cap (a strong
signal it was truncated; not surfaced via $notify because the
typical user can't act on it).
- chipState drops the additions arrays - additions never enter
chipState anymore - and confirmAlbums migrates from raw
$api.delete/post to Photo.removeFromAlbum, so all album-
membership writes share one model code path.
Failure paths surface $notify.error("Failed to save changes") and
do not leave a transient chip behind. Discard-pending dialog still
fires for typed-but-uncommitted text (chipSearch) and for batched
removals.
The helper is reused for album titles in upcoming sidebar work, so
the label-only name no longer fits. Renames the static method on
$util plus the matching wrapper (normalizeLabelTitleForCompare ->
normalizeTitleForCompare) in batch-edit.vue, the component prop
contract stays the same. No behavior change.
Adds two model methods on Photo for album-membership writes that
mirror the backend album-photo endpoints:
- Photo.addToAlbum(albumUID): POST /albums/<uid>/photos with
{ photos: [this.UID] }, then evict the LRU cache and refetch the
canonical photo so this.Albums reflects the saved state.
- Photo.removeFromAlbum(albumUID): DELETE /albums/<uid>/photos with
the same evict + refind.
The explicit refind is necessary because the backend publishes
albums.updated (not photos.updated) for membership changes, so the
photos.updated WS subscriber doesn't auto-evict here.
The Photo-level method names overlap with Thumb.addToAlbum /
removeFromAlbum on purpose: those operate at the photo-grid layer
and flip a Removed flag for lightbox menu visibility (see
lightbox.vue onRemoveFromAlbum). Both contracts are pinned in
their respective tests so a future "consolidation" PR doesn't
collapse them.
Replace the unconditional @keydown.tab.stop on the v-dialog with
@keydown.tab="onTabKey" plus a runtime guard: stopPropagation now
fires only when document.activeElement is inside this.$refs.container
(or .content as a fallback). PhotoSwipe's _focusRoot() Tab trap stays
suppressed for the typical case (focus inside the lightbox tree, user
tabbing through chips and pencils), and a Tab event that bubbles up
through the dialog from a teleported autocomplete menu or any other
DOM context outside the lightbox tree can still reach PhotoSwipe.
Adds five unit tests covering the inside / outside / fallback / no-ref
/ no-event branches.
hasPendingEdit() was missing two signals, so swiping or pressing the
arrow keys silently dropped typed-but-uncommitted text in the labels
combobox / albums autocomplete and the open Add-name marker prompt.
Add both: typed text in chipSearch while editingField is 'labels' or
'albums', and addNameDialog.visible. resetInlineEdits() also dismisses
an open Add-name dialog so the Discard path leaves the sidebar fully
clean. Existing wrapPswpNavGuards / onChange post-facto rollback already
route through this method, so the discard confirmation now appears for
the missed cases without any new wiring.
Add .stop to every @keydown handler on sidebar editors and the three
nested dialogs (datetime, camera, location). The Vuetify v-dialog above
the lightbox listens for @keydown.esc.exact.stop=close, and PhotoSwipe's
document handler still consumes a few printable keys. Without .stop,
Esc on a textarea closed the editor AND the lightbox, Esc on a nested
dialog closed both modals at once, and Esc on the labels combobox /
albums autocomplete closed the lightbox without ever cancelling the
chip edit (Vuetify's default escape doesn't stop propagation).
Also: add @keydown.escape.stop.prevent=cancelEditing to the labels
combobox and albums autocomplete (their template had no escape
handler), and call clearChipInput() inside cancelEditing so typed-but-
uncommitted chip text doesn't survive a cancel.
Move Tab interception from the PhotoSwipe-event bridge (onPswpKeyDown)
to a generic v-dialog @keydown.tab.stop handler. PhotoSwipe's Tab path
only calls _focusRoot() — a focus trap that Vuetify's v-dialog and
PhotoPrism's $view trap (common/view.js) already provide more
generically — so stopping the bubble at the dialog root is enough,
no PhotoSwipe-specific hook required. onPswpKeyDown stays for the
"z" / printable-key case where we still need to defeat the
toggleZoom shortcut while a textarea or input is focused.
Also: shift the .meta-chip:focus outline to outline-offset: -2px so
the focus ring renders inside the chip area instead of being clipped
when chips wrap against the sidebar edge.
PhotoSwipe's _onKeyDown listens on document and consumes "z" for
toggleZoom and Tab for _focusRoot, so plain typing into the info
sidebar's textareas dropped "z" and Tab snapped focus back to the
lightbox root, leaving sidebar chips and pencils unreachable by
keyboard. Add an onPswpKeyDown bridge that listens via the lightbox's
own keydown event and calls preventDefault on the dispatched event so
PhotoSwipe's switch returns early (per its own defaultPrevented gate).
Two cases: Tab while the sidebar is open (regardless of focus), and
any key when an INPUT, TEXTAREA, or contentEditable element inside
the sidebar is focused. Registered once via this.lightbox.on, which
PhotoSwipe Lightbox replays onto each pswp instance on open.
Make label and album chips focusable (tabindex=0) and bind Enter +
Delete/Backspace via two new helpers: onChipActivate covers click and
Enter (navigates outside edit mode, toggles pending removal inside),
onChipDelete covers Delete and Backspace (no-op outside edit mode,
toggles inside). Vue 3's .delete keydown modifier captures both Delete
and Backspace, so one binding handles both keys.
Also: tighten the cache-freshness comment in confirmLabels to name the
.then((r) => this.setValues(r.data)) chain on Photo.addLabel /
removeLabel, so the next reader can verify the local-patch contract
without grepping model/photo.js. Resolves the documentation half of
proposal item L5 (the asymmetry with confirmAlbums is intentional and
already justified).
Replace four flat arrays (pendingLabel{Additions,Removals},
pendingAlbum{Additions,Removals}) with one chipState object keyed by
field, plus four generic helpers (isChipPendingRemoval,
togglePendingChipRemoval, removePendingChipAdd, resetChipState) that
take field + key. addPendingLabel / addPendingAlbum stay specialized
because their validation differs (string vs object input).
Also: route the chip-section header check/pencil through the existing
PSidebarInlineToolbar; add meta-labels / meta-albums classes for
parity with the per-field hooks; surface a notify.error on
confirmLabels / confirmAlbums rejection (was silent); document the
local-patch vs evict-and-refind asymmetry between addLabel/removeLabel
and the raw album-membership API; suppress the empty .metadata__chips
container when entering edit mode with zero existing chips and zero
pending additions, and hide the redundant 'Add label/album' prompt
while editing. Tests updated to the new state shape and method
signatures (140 sidebar/info tests still pass).
Replace four near-identical Subject/Artist/Copyright/License blocks
and the parallel Keywords/Notes blocks with two v-for loops over a
shared fieldRegistry computed (key, label, icon, read/write, display
mode, optional sanitized-html computed pointer). The registry is also
the lookup table for getFieldValue/setFieldValue, so each field's
label, model path, and rendering rule live in exactly one place.
Also: extract the check/pencil icon pair into PSidebarInlineToolbar,
swap the shared template ref for a function ref so the focus path
keeps working when the editors live inside v-for, and add
showDetailsSection/shouldShowFieldRow computed to replace the 6-term
OR gating expression. Net diff: -272 / +181 lines, no behavior change.
startEditing() used querySelector(.meta-inline-edit input) which matched
the first such element in the DOM. The People combobox shares that class
and renders above subject/artist/copyright/license/keywords/notes, so
clicking any of those add-prompts focused a marker input instead of the
clicked field. Replace the DOM query with a shared template ref on the
mutually-exclusive inline editors and add meta-<field> / meta-inline-<field>
hooks to all rows for consistent styling and test selectors.
AGENTS.md: add frontend/ to package-doc paths and point at
frontend/README.md for pin/override/dep policy.
frontend/AGENTS.md: new "Dependencies & Pins" section briefs agents
on the pins-are-intentional rule, the workspace npm install location,
and the orphan-audit pattern.
frontend/CODEMAP.md: trim the duplicated Vuetify pin paragraph and
link to frontend/README.md as the canonical rationale; keep the
upstream-issue caveats since they relate to component bindings.
Refresh Last Updated dates on all four files.
frontend-rules.md: new "Frontend Dependencies & Pins" section
points at frontend/README.md as the canonical pin doc, restates
the pins-are-intentional rule, and documents the workspace npm
install location plus the orphan-audit pattern.
commit-and-docs-style.md: explicit no-specs/-in-public rule with
the AGENTS.md/CLAUDE.md exception; note that gh issue create lacks
a --type flag so issue type must be set via the web UI.
sources-of-truth.md: add frontend/README.md to the package-README
list and call out its pin/override/orphan content directly.
The "minimatch@~3.0": "^3.1.3" override was added on 2026-04-27 to
force-patch the ReDoS chain when postcss-url's locked minimatch@3.0.8
was the active vulnerability. With postcss-url removed, no consumer
in the tree pins to ~3.0.x anymore — every remaining minimatch
request (^3.0.4 from babel-plugin-istanbul/test-exclude and
eslint-plugin-node, ^3.1.5 from the eslint family, ^9.0.4 / ^10.1.1
from modern tooling) resolves naturally to a patched version.
Verification: removing the override leaves the lockfile byte-identical
("up to date in 932ms"), npm audit reports zero vulnerabilities, and
all installed minimatch versions are 3.1.5 / 5.1.x / 9.0.9 / 10.2.5
(no <3.1.4 entry). Build and 1055 vitest tests pass.
Both were added during the Vitest framework integration (#4990) but
never referenced in any source file or test config — npm ls confirms
zero transitive consumers. PhotoPrism is Vue, so the React testing
library is not applicable; @testing-library/jest-dom (the actually
used helper) stays. The TS-paths resolver was speculatively added
when the Vite config was first stubbed and was never wired up.
README's "Known Unused" table is replaced with a short "Auditing for
Orphaned Dependencies" section so the sweep pattern (rg + npm ls)
is documented for future cleanups.
Cheerio was originally pulled in alongside easygettext@2.17.0 in May
2022 (commit d600d5faf), where easygettext declared cheerio@^1.0.0-rc.3
as a runtime dep for HTML parsing in the gettext-extract flow. When
easygettext was replaced by vue3-gettext / vue-gettext-extract in
October 2025 (commit ab9a0a969 / #1152), cheerio was overlooked and
left as a top-level dep with no consumers.
The exact pin to 1.0.0-rc.12 was set in September 2024 (commit
ab9e156c9, bundled into an unrelated UX commit) after an August 2024
bump to ^1.0.0 broke easygettext's ^1.0.0-rc.3 peer constraint. That
rationale evaporated when easygettext was removed; the lockfile
reverse-search confirms zero current consumers.
npm ls cheerio --all returns only the direct top-level entry; no
source files import it. make audit, make build-js, make test-js
(1055 passed / 13 skipped) all stay clean after removal.
Sweeps top-level CODEMAP/GLOSSARY, package READMEs under internal/,
and a handful of source-comment references that linked to the private
specs/ subrepo. AGENTS.md hints stay (agent-instruction document) and
the agent-skill linking targets in Makefile are guarded with
directory checks.
Captures why specific packages are pinned (vuetify, axios, cheerio),
which transitive overrides exist and why, and which major upgrades
are currently blocked by CJS config files. Includes add/remove/bump
checklists so future contributors can avoid duplicate research.
The supply-chain compromise that drove the original 1.14.0 quarantine
(malicious 1.14.1/0.30.4 from a hijacked maintainer account) was fully
remediated upstream weeks ago, so 1.16.0 is safe. Keeps the exact-pin
style recommended for high-risk packages.
Removes two stale dependencies that have no code references in any
frontend/, plus/frontend/, pro/frontend/, or portal/frontend/ tree.
postcss-url was a 2019 leftover and is the only consumer of the
unpatched minimatch <=3.1.3 ReDoS chain (GHSA-3ppc-4f35-3m26,
GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74); npm audit now reports
zero vulnerabilities.
The jsdom upgrade to 29.x makes the default virtualConsole forward
"Could not parse CSS stylesheet" jsdomErrors to stderr, flooding
make test-js with one bare line per Vuetify-flavored stylesheet.
Drop only the noise across that surface; surface other CSS errors
with their parser cause.
Vuetify 3.12.3 added an onFocusout handler to VAutocomplete, VSelect,
and VCombobox (commit 3d33e2f, fixing Vuetify upstream #22697) that
flips isFocused=false on any blur whose relatedTarget is outside the
textfield. The handler closes long dropdowns on open because the
virtual scroller unmounts the focused list item during the scroll-to-
selected-index pass that runs right after the menu opens, producing a
transient blur with relatedTarget=null. Pinning Vuetify to 3.12.2 (the
last release without the regression) is the cleanest fix; explored
workarounds (list-props navigation-strategy=track, capture-phase
focusout suppression with activator refocus, menu-props attach=true)
all had unacceptable side effects.
The pin is documented inline in three places that cross-reference each
other: package.json (//vuetify field), frontend/CODEMAP.md (Runtime &
Plugins), and frontend/src/common/view.js (sibling-menu gate comment
block). The original GitHub issue #5538 has been updated with the full
root-cause analysis. A separate follow-up issue #5556 tracks the minor
.v-field--focused linger that 3.12.2 still has (PhotoPrism is currently
unaffected because the relevant inputs aren't on screen together).
Includes incidental cssnano (7.1.8 -> 7.1.9) and postcss (8.5.13 ->
8.5.14) patch bumps from \`make -C frontend update\` after the pin.
Bumps:
- github.com/go-sql-driver/mysql v1.9.3 -> v1.10.0
- github.com/modelcontextprotocol/go-sdk v1.5.0 -> v1.6.0
- github.com/yalue/onnxruntime_go v1.28.0 -> v1.30.0
go-sdk v1.6.0 dropped the implicit default that previously rejected
cross-origin requests when StreamableHTTPOptions.CrossOriginProtection
was nil (modelcontextprotocol/go-sdk#906). internal/api/mcp.go now
sets the field explicitly so that /api/v1/mcp keeps verifying the
Origin header on the streamable HTTP transport.
onnxruntime_go v1.30.0 stays at ORT_API_VERSION 25, so the bundled
native runtime does not need to change for this bump alone.
internal/server/README.md still listed both `If-Modified-Since` and
`If-None-Match` as active validators for `PrecompressedStatic`. The
handler does not set an `ETag`, so the active validator pair on
`/static/*` and `/c/static/*` is `Last-Modified` + `If-Modified-Since`
only — matching the doc comment in `static_precompressed.go` and the
"Conditional requests" subsection of `specs/platform/http-compression.md`.
The repo Makefile and dev image both export NPM_CONFIG_IGNORE_SCRIPTS=true
to harden npm against malicious supply-chain scripts. That flag also
suppresses lifecycle hooks like postbuild and prewatch, so the
precompress hooks registered in frontend/package.json never fire under
the standard build flow. Wiring `node scripts/precompress.js`
explicitly into build-js (and `--clean` into watch-js) keeps the
security-conscious npm flag intact while guaranteeing siblings exist
after every release-track build and that stale siblings are wiped
before watch-mode dev iteration starts.
The npm hooks remain registered as a backup for ad-hoc `npm run build`
invocations where IGNORE_SCRIPTS is off.
Adds PrecompressedStatic handler in internal/server/static_precompressed.go,
wired by routes_static.go for /static/*filepath (bundled) and
/c/static/*filepath (custom). When a .zst or .gz sibling is on disk and
the client's Accept-Encoding allows it, the handler streams the
sibling via http.ServeContent — preserving Last-Modified and
If-Modified-Since revalidation, deriving Content-Type from the identity
filename so service-worker registration and SRI continue to work, and
setting an explicit Content-Length that http.ServeContent omits when
Content-Encoding is in effect.
Range requests always serve identity (the byte offsets in Content-Range
correspond to identity bytes), and PHOTOPRISM_HTTP_COMPRESSION=none
disables encoded-sibling selection so the operator switch stays
consistent with the runtime middleware. Vary: Accept-Encoding is
emitted only when the response could realistically vary by encoding.
Both /static/* and /c/static/* are added to NewShouldCompressFn's
exclusion list so the runtime middleware never re-encodes an
already-encoded body.
Adds frontend/scripts/precompress.js, a Node script that walks
assets/static/build/ and emits .gz (zlib level 9) and .zst (zlib level
19) siblings for every JS, CSS, JSON, SVG, font, source map, and small
text asset. Already-encoded binaries (woff2, png, jpg, ...) and
no-savings files (smaller than 1 KiB or compressing to >95% of source)
are skipped. Every emitted sibling is round-tripped through
gunzipSync / zstdDecompressSync so a corrupt sibling never reaches
disk.
Wired as the npm `postbuild` hook so `npm run build` produces siblings,
and as `prewatch` (`--clean`) so `npm run watch` removes any stale
siblings from a prior production build before webpack's first emit.
The built-in node:zlib zstd API requires Node 22.15 or later; engines
constraint bumped accordingly and frontend/tests/README.md aligned.
Removes 5 trailing spaces between the value and the # comment on the
PHOTOPRISM_HTTP_COMPRESSION line so it lines up with neighboring
PHOTOPRISM_* entries again. Cosmetic-only follow-up to the zstd
value flip, which left the comment column 5 cols too far right in
the files where the previous spacing was preserved verbatim.
UpdateCountsAsync and UpdateCoversAsync now register with a shared
WaitGroup so config.CloseDb can drain in-flight goroutines via
entity.WaitForAsyncJobs before nilling the DB provider. Both
synchronous helpers also bail out cleanly when entity.Db() is nil,
and the async wrappers recover so future shutdown races log instead
of crashing the test process.
Flips the operator-facing default across all sample compose files,
the Linux package defaults, and the Portainer stack env so QA
exercises zstd end-to-end before the next release. The armv7
samples keep their "none" value to spare the CPU on small devices;
only the comment was broadened.
Negotiates zstd alongside gzip via PHOTOPRISM_HTTP_COMPRESSION as a
comma-separated preference list (e.g. "zstd,gzip"); legacy "gzip",
"none", and the empty string keep behaving as before. Bypasses
Connection: Upgrade, predicate-excluded paths, HEAD (with Vary kept),
4xx/5xx, 206, 204, and 304. Weakens strong ETags on the encoding path
so caches don't reuse a compressed body for a different coding.
When invoked as "sudo bash install-nodejs.sh" by an unprivileged user, "~"
expanded to the calling user's home before sudo elevated, so root's
".npmrc" ended up with "cache=/home/<user>/.cache/npm" and subsequent
"sudo npm install -g" runs left root-owned cache dirs inside the user's
home. Hardcode the path so the cache always lands under root.
Convert success-path tests for Thumb.archive / restore /
removeFromAlbum to the global axios-mock-adapter Mock from
fixtures.js, with URL + payload pinned through Mock.history. Add
persistent mocks for batch/photos/restore and albums/:uid/photos
(DELETE) so the new tests have something to land on.
Rejection tests intentionally keep vi.spyOn($api, ...).
mockRejectedValueOnce(err) — axios-mock-adapter matches handlers
in registration order, so a replyOnce(500) registered in a test
cannot override the persistent reply(200) already declared in
fixtures.js. Documented inline in the archive describe.
Signed-off-by: Michael Mayer <michael@photoprism.app>
Refresh the doc comments on Photo.archive() and Thumb.archive() /
restore() / removeFromAlbum() to spell out why the optimistic
Archived/Removed flip lives only on Thumb (the lightbox menu reads
this.model?.Archived directly) while Photo.archive() stays minimal
(no Vue component reads photo.Archived; the photos.archived WS
handler in page/photos.vue removes the row via removeResult).
Comment-only change, no behavior change.
Signed-off-by: Michael Mayer <michael@photoprism.app>
confirmCamera() applied the dialog payload optimistically and then
called photo.update() fire-and-forget. A rejected save left the
sidebar showing values that never reached the backend with no error
surfaced. Now the handler attaches a .catch that calls
photo.rollback() — Photo.update() only resets __originalValues on
success, so the snapshot still holds the pre-mutation state on
failure — and notifies the user via $notify.error.
Adds three Vitest cases: optimistic mutation rolls back when
update() rejects, stays applied when it resolves, and is skipped
when the photo has no UID.
Signed-off-by: Michael Mayer <michael@photoprism.app>
The base Model now exposes a generic rollback() that restores every
tracked field from __originalValues (deep-cloning objects so post-
rollback mutations don't bleed into the snapshot). It's the natural
inverse of wasChanged() and lets optimistic-then-fail flows undo a
local mutation without per-field bookkeeping at the call site.
Adds doc comments to constructor / setValues / getValues /
originalValue / wasChanged / getDefaults explaining the
__originalValues snapshot contract, the scalarOnly flag, and how
getValues() coercion uses getDefaults() type hints.
Expands rest.test.js to cover the previously-untested paths:
setValues scalarOnly + chaining + reserved-key handling, getValues
changed-flag + type coercion + pass-through, originalValue lookup
and fallback, wasChanged true/false/post-rollback, plus four
rollback cases (scalar, deep-cloned object, chaining, no-op).
Signed-off-by: Michael Mayer <michael@photoprism.app>
Apply the pattern from photo/edit/labels.vue. The lightbox is a $view
scope, so the sidebar can read parent state through
`view: this.$view.getData()` and write back to it without triggering
vue/no-mutating-props. Collapses 8 prop bindings on <p-sidebar-info>
to :uid="model.UID", drops update:modelValue, and initializes
lightbox.photo to new Photo() so the sidebar can read view.photo.X
without nullable chains.
Resolves the 15 vue/no-mutating-props errors flagged in the #5505
review.
Signed-off-by: Michael Mayer <michael@photoprism.app>
The backend already publishes "photos.updated" / "photos.deleted" via
PublishPhotoEvent (internal/api/api_event.go) on every API mutation.
Listen for them in model/photo.js so the LRU stays in sync with backend
state without each mutator having to remember to evict. Drops the 13
manual Photo.evictCache(this.UID) calls from toggleLike, togglePrivate,
like, unlike, addLabel, activateLabel, renameLabel, removeLabel,
setPrimaryFile, unstackFile, deleteFile, changeFileOrientation,
and update.
Also covers external mutations (other tabs, indexer, batch operations)
that previously left stale cache entries.
Signed-off-by: Michael Mayer <michael@photoprism.app>
install-chrome.sh now pins Debian Bookworm to apt priority 100 with
chromium-* elevated to 990, preventing apt from "upgrading" unrelated
Ubuntu packages like libjpeg-dev to Bookworm's higher-versioned one
(which would pull libjpeg62-turbo-dev and collide with Ubuntu's
libjpeg-turbo8-dev). The conflict affected all Ubuntu+Bookworm-
chromium ARM64 builds; Resolute happened to surface it first.
The Resolute Dockerfiles now install libheif (1.21.2) from the
Resolute apt repo and explicitly pull the codec plugins, replacing
the older 1.20.2 archive previously fetched by install-libheif.sh.
Signed-off-by: Michael Mayer <michael@photoprism.app>
Adds docker/develop/resolute/ and docker/develop/resolute-slim/ as
direct clones of the questing images with the base image, OCI labels,
and top-of-file comment switched to resolute. Test builds can be created
once the final "ubuntu:resolute" image is published on Docker Hub.
Signed-off-by: Michael Mayer <michael@photoprism.app>
Adds docker/develop/trixie/ (full) and docker/develop/trixie-slim/
images based on the Bookworm images, with ImageMagick 7 policy path,
s6-overlay parity with Questing, and Trixie packages for libjxl,
va-driver, nvenc, postgresql-client, and ripgrep.
Signed-off-by: Michael Mayer <michael@photoprism.app>
Ubuntu 26.04 ("resolute") and Debian trixie+ no longer ship the
"cgroupfs-mount" package — modern systemd handles cgroup v2 mounting
natively. With the package gone from the index, the existing
"apt-get install" line aborted with:
E: Package 'cgroupfs-mount' has no installation candidate
before Docker itself was installed. Probe the index via "apt-cache show"
and include the package only when it is actually available; older
distros that still ship it are unaffected.
Two issues prevented the script from running cleanly when piped into
sudo (the most reliable invocation over SSH):
1. The header recommended `bash <(curl …)`, but with sudo the process
substitution opens /dev/fd/63 in the unprivileged parent shell and
the elevated bash cannot read it — the script aborted with
`bash: /dev/fd/63: No such file or directory`. Replace with the
`curl -fsSL … | sudo bash` pattern (and keep an explicit
download-then-run fallback). Also call out the broken sudo + process
substitution form so users do not retry it.
2. With stdin piped to bash, `gpg --dearmor` tried to write status to
/dev/tty and failed with `gpg: cannot open '/dev/tty'`, breaking the
keyring import (and silently leaving an empty keyring on a fresh
host). Pass `--no-tty --batch --yes` to both gpg calls.
Tested end-to-end on Ubuntu 26.04 ("resolute") via
`curl -fsSL …/install-chrome.sh | sudo bash` from a clean state
(no preexisting keyring or apt list): keyring valid OpenPGP file, deb
fetched and installed, `google-chrome --version` returns 147.0.7727.116.
- Flag <name>.ServeHTTP(<writer>, c.Request) calls without a nearby
LimitRequestBodyBytes so SDK-delegated handlers (MCP today, future
SDKs tomorrow) are held to the same body-cap contract as BindJSON.
- Also flag direct reads via io.ReadAll, io.LimitReader,
json.NewDecoder, xml.NewDecoder, and yaml.NewDecoder on c.Request.Body
Signed-off-by: Michael Mayer <michael@photoprism.app>
Set BUILDKIT_STEP_LOG_MAX_SIZE=5 MiB and BUILDKIT_STEP_LOG_MAX_SPEED=10 MiB/s
on both the local and remote ssh://arm multibuilder nodes so long apt-get
transcripts on unstable Ubuntu bases aren't truncated during preview builds.
- Rewrite package doc, struct comments, log line, and route-registration
comment so they describe the MCP server as a first-class feature
rather than a pre-release prototype. The "Usage" strings on
MCPCommands / MCPServeCommand were updated separately and stay
unchanged.
- Drop "in this prototype" from the edition-advisory warning emitted by
list_config_keys; the message now reads "edition filtering is
advisory; results come from the current <edition> build metadata".
- Rewrite internal/mcp/README.md: new "Scope" section with
in-scope/out-of-scope invariants, corrected authorization summary
(admin + API client roles + manager in Pro/Portal, anonymous in
public mode for the registered read-only tools), and a clearer
"Current Capabilities" block.
Signed-off-by: Michael Mayer <michael@photoprism.app>
- Drop the public-mode 403 short-circuit in internal/api/mcp.go. In
public mode api.Session() returns the default public session, so
Auth(c, acl.ResourceMCP, acl.ActionView) + s.Abort(c) authorize
anonymous callers naturally. This enables the prototype to run on
demo.photoprism.app and similar showcase installations without
loosening the auth path or adding another toggle.
- The exemption is only safe because every currently registered tool
returns static reference metadata derived from config.Flags and
form.Report(&form.SearchPhotos{}) — no database access, no per-user
state, no secrets, no mutations. A handler-level comment captures
that contract so a future tool that does not fit cannot slip in
unnoticed.
- Flip the public-mode test case (ForbiddenPublicMode ->
AllowedPublicMode) in internal/api/mcp_test.go to a full anonymous
initialize + notifications/initialized + tools/call round-trip
against both registered tools, so any future tightening of the
policy regresses loudly.
- Update internal/mcp/README.md: the Authorization bullets now
describe the public-mode path, the Rate Limiting note reflects that
CE/Plus have no generic throttle in public mode, and a new
"Extending the Tool Surface" section documents the contract every
new tool must uphold plus the three supported extension paths
(two-server factory, per-tool context checks, SDK middleware).
Signed-off-by: Michael Mayer <michael@photoprism.app>
- add scripts/dist/install-libvips.sh for Jammy backport installs
- switch jammy and jammy-slim Dockerfiles to use the new installer
- document libvips 8.14+ requirement in setup/pkg/linux docs
- update thumb/README.md and regenerate setup/pkg/linux/README.html
Signed-off-by: Michael Mayer <michael@photoprism.app>
* OIDC: Add handling to AuthID so that SQLite doesn't corrupt on save with long numbers
* Entity: Ensure that AuthID wrap/unwrap is used for auth_user and auth_sessions, and that auth_sessions wrap/unwrap on create/save/find as required
* Entity: hard code toggle off wrap/unwrap, implement dbms migration change to pre-create/alter tables with auth_id in SQLite and pre-create for MariaDB
* Entity: revert wrap/unwrap for AuthID
* Migrate: Comment out MySQL statements required for GormV2
* Entity: revert authID to authId as per review
* Migrate: wrap authid changes in transaction (rollback if any errors), and drop migration tables
* migrate: correct tx.Error use, impove commit/rollback so no silent failures, remove excess err variable.
Vuetify 3.12.0 includes breaking changes for VTreeview and VList:
reworked indentation and spacing in trees and nested lists
see https://github.com/vuetifyjs/vuetify/releases/tag/v3.12.0
Signed-off-by: Michael Mayer <michael@photoprism.app>
Run `go fix ./...` and keep mechanical modernization updates.
- Replace `interface{}` with `any` in signatures and local types
- Apply formatter/style cleanups from go1.26 tooling
- Keep `omitempty` behavior-preserving simplifications suggested by fix
- No functional feature changes intended
Validation:
- go test ./... -run '^$' -count=1 (Go 1.26.0)
- GOTOOLCHAIN=go1.24.10 go test ./... -run '^$' -count=1
Signed-off-by: Michael Mayer <michael@photoprism.app>
* Tests: Improve test isolation and cleanup in vitest suites
* Tests: Add component tests for navigation, photo toolbar, and file editing
* Tests: Remove unused notification spies from batch edit component tests
* Tests: update Vitest `$notify` mock to use `vi.fn()` and include `info` method.
Config.DefaultsYaml() resolves the default options YAML file. When
PHOTOPRISM_DEFAULTS_YAML points to a readable file it will be used;
otherwise it falls back to `defaults.{yml,yaml}` inside the active
config directory.
This allows instances without `/etc/photoprism/defaults.yml` to
still load local defaults, e.g., in containerized environments.
Signed-off-by: Michael Mayer <michael@photoprism.app>
* Frontend: Improve people name editing and confirmation UX
Adds better menu control and confirmation dialog handling for editing people names in photo edit and new people pages. Ensures only one menu is open at a time, improves keyboard accessibility, and prevents conflicting confirmation dialogs. Also updates event handling and emits for dialog and people components.
* Frotend: clear model fields on cancel in people dialogs #5145
* Frontend: Enable menu opening on click in people edit and new pages #5145
* Frontend: Enhance confirmation dialog with improved keyboard accessibility #5145
* Frontend: Refactor name confirmation handling in people edit and new pages #5145
* Frontend: Update name setting logic in people edit and new pages #5145
* People: Adjust menuProps and add focus trap to PConfirmDialog #5307
Signed-off-by: Michael Mayer <michael@photoprism.app>
---------
Signed-off-by: Michael Mayer <michael@photoprism.app>
Co-authored-by: Michael Mayer <michael@photoprism.app>
* Tests: improve openNav, and add ability to close all event notifications.
* Tests: improve search with wait for notifications to expire, then using notifications to determine search completion.
* Tests: improve getPhotoCount by using notifications for completion of photo load. Add fast and slow photo load waits.
* Tests: add fast and slow load detection via notifications
* Tests: replace waits with notification detection
* Tests: refactor tests to work more efficiently with notifications
* Tests: comment out execution time and p-notify__close logging. Remove slow version of waits.
* Make: allow acceptance tests to be run with --ignore-errors, and make each multi window test run as separate instance
* Frontend: ensure that closing a notification resets the timer for the next notification
* Tests: move notification handling to single notifications.js file
* Tests: move photoCount as changing cards view doesn't generate event when there is one photo
* Tests: remove commented code
* Tests: enable environment variable SHOW_LOGS (true) to show timings and click actions for waits
* Tests: add Delete, Import, Index, Unstack, and Upload notification waits.
This commit adds the following new config options:
- FaceClusterRadius
- FaceCollisionDist
- FaceEpsilonDist
- FaceMatchChildren
- FaceMatchBackground
Signed-off-by: Michael Mayer <michael@photoprism.app>
* Frontend: add multi-select to Add to Album and support multi-target actions
* Tests: Adapt acceptance tests to changes
* Frontend: Enhance clipboard functionality by adding input validation and deduplication for album UIDs across multiple components
* Tests: Enhance album functionality tests to support adding/removing photos from multiple albums and address album duplication bug
* Tests: Improve page mdoel and remove .only
* Frontend: Increase max-width of photo album dialog from 390 to 500
* Tests: Remove unused triggerAlbumDialogAndType method
* Frontend: Remove deep watcher from album dialog and implement deduplication logic for selected albums in upload dialog
* Frontend: Update album dialog placeholder text
* Tests: Refactor album duplication test for clarity and consistency
* Tests: Cover additional test cases
* Tests: Improve acceptance tests
* Frontend: Remove error logging
* Frontend: Add utility functions for album selection and implement watcher in dialog
* Frontend: Enhance album creation logic to handle partial failures and improve user feedback
* Frontend: Fix title casing for album selection chips in dialog
* Frontend: Fix title rendering logic for album selection chips in dialog
---------
Co-authored-by: graciousgrey <theresagresch@gmail.com>
* Frontend: Add 3-dot menu for face actions in people page #4151#797
* Tests: Add unit test for manual cover setting in subjects API #4151 and #797
* Tests: Add unit tests for face actions in PeopleTab component #4151#797
* Tests: Enhance face action functionality in PeopleTab with new tests for removing faces and setting person cover #4151#797
* Tests: Remove unused Karma configuration and test files
This commit deletes the Karma configuration file and test files that are no longer needed
* Tests: Refactor setup file for Vitest and remove legacy vue-setup.js
* Tests: Update truncate test case to use Vitest string
* Tests: Add unit tests for PLocationInput component using Vitest
* Tests: Improve unit tests for PLoadingBar component
* Tests: Move karma test to vitest
* Frontend: Update deps in package-lock.json
* Tests: Adapt test to vitest
* Tests: Refactor Makefile and package.json for test commands
This also renames the Meta option to TensorFlow so it is clear these
values are to configure TensorFlow models only.
Signed-off-by: Michael Mayer <michael@photoprism.app>
* AI: Added support for non BHWC models
Tensorflow models use BHWC by default, however, if we are using
converted models, we can find that the expected input is BCHW. Now the
input is configurable (although the restriction of being dimesion 4 is
still there) via Shape parameter on the input definition. Also, the
model instrospection will try to deduce the input shape from the model
signature.
* AI: Added more tests for enum parsing
ShapeComponent was missing from the tests
* AI: Modified external tests to the new url
The path has been moved from tensorflow/vision to tensorflow/models
* AI: Moved the builder to the model to reuse it
It should reduce the amount of allocations done
* AI: fixed errors after merge
Mainly incorrect paths and duplicated variables
New parameters have been added to define the input of the models:
* ResizeOperation: by default center-crop was being performed, now it is
configurable.
* InputOrder: by default RGB was being used as the order for the array
values of the input tensor, now it can be configured.
* InputInterval has been changed to InputIntervals (an slice). This
means that every channel can have its own interval conversion.
* InputInterval can define now stddev and mean, because sometimes
instead of adjusting the interval, the stddev and mean of the training
data should be use.
This adds initial cloud-init configuration files for the automatic installation of PhotoPrism on a Raspberry Pi. The files include metadata, network configuration, and an in-depth user-data script that installs Docker, configures paths, sets services, and prepares the system for PhotoPrism with Traefik and MariaDB.
* Frontend: Map Dialog first version
* Frontend: Simplify street address handling and remove reset location control in map dialog
* Frontend: Enhance map undo functionality
* Frontend: Refactor location info handling to use backend API for reverse geocoding
* Frontend: Improve coordinate input handling
* Frontend: Adjust layout in photo details
* Frontend: Add feature to clear coordinates
* Frontend: Update button alignment
* Frontend: Implement place search feature in map dialog
* Setup: Update Digital Ocean Docker Image
* Setup: update digital ocean docs
* Setup: Update Docker CE version in cloud README
* Setup: Update Traefik version to 3.4
* Setup: Update software versions in DigitalOcean README
The prometheus text format requires metrics endpoints respond with the
content-type 'text/plain; version=0.0.4'. Without this, newer versions
of prometheus fail to scrape the metrics endpoint and report an error.
It's possible to work around this by setting the
'fallback_scrape_protocol' setting in the prometheus scrape target
configuration, but this revision sets the content type appropriately to
avoid this in the first place.
* Tests: convert all common tests from mocha to karma
* Tests: refactor Vuetify setup in tests
* Tests: update package-lock.json
* Tests: convert all model test to vitest 1/2
* Tests: convert all model test to vitest 2/2
* Tests: fix broken test
* Tests: time zone UTC
* Tests: Add playwright screenshots folder to gitignore
* Tests: Add timezone to vitest scripts
* Tests: Add Vitest scripts to Makefile
* Tests: delete unused timezone configs
* Tests: Update some tests
* Tests: Update vitest config
* Tests: Delete usesless try-catch
The "photos" array in the response has been renamed to "models". This
data is now always returned, so using the "return" flag in the request
is no longer necessary.
Signed-off-by: Michael Mayer <michael@photoprism.app>
- Includes fixtures and mocks system for API and models as well as npm scripts for running tests, watch mode, coverage and UI
- Adds test setup with JSDOM environment and utility function tests
- Converts marker model tests from Mocha/Chai to Vitest
* Backend: Clean up Unix socket file on shutdown
* Backend: Clean up Unix socket file on startup
* Backend: Clean up Unix socket file
* Refactor Unix socket cleanup in server startup process
* Viewer: Reorganize the sidebar to display additional metadata attributes based on file type #4812
* Viewer: Add map view to sidebar #4812
* Viewer: Improve sidebar metadata spacing. #4812
* Viewer: Optimize megapixels calculation #4812
* Viewer: Move styles to lightbox.css #4812
* Viewer: Removing click event from Lat Lng #4812
* Viewer: Adjust css for map
* Viewer: extract map component from sidebar info
* fix: restore actions array in sidebar info
* Viewer: Offline map feature removed from sidebar
* Viewer: Use TakenAtLocal for consistent sidebar date format
* Viewer: Add zoom controls to the map component
* Viewer: Add map attribution in sidebar with default collapsed state
Now when loading labels internal/ai/tensorflow package will try to look
for all the files that match the glob label*.txt and will return the
labels that match the expected number. Some models add a first label
called background, which is a bias.
Also, a new parameter has been added to models to allow a second path to
look for the label files. This path is set to nasnet asset on
internal/ai/vision.
By inspecting existing models we saw that many times logits are returned
instead of probabilities. Photoprism uses probabilites to rank the
quality of the results, so we need to transform those logits. Our
approach is to add a new layer at runtime to the graph that performs the
softmax operation.
Required for compatibility with the Python HTTP client. In addition,
this commit refactors function names and adds tests.
Signed-off-by: Michael Mayer <michael@photoprism.app>
This is necessary to provide external services with temporary access to
specific media files without giving them permanent access, a regular
download, or an access token.
Signed-off-by: Michael Mayer <michael@photoprism.app>
This is a proof of concept and still under development. The other
Vision API endpoints are stubs for testing and not yet functional.
Signed-off-by: Michael Mayer <michael@photoprism.app>
These changes allow to configure the computer vision models through an
optional vision.yml configuration file. Note that the API endpoints
are not yet functional and require further work.
Signed-off-by: Michael Mayer <michael@photoprism.app>
This should optimize for fast execution as much as possible without
significantly increasing the binary size.
Signed-off-by: Michael Mayer <michael@photoprism.app>
* Fixed create_archive.sh
The previous version followed symlinks on doing the copy to a temporary
folder, so the resulting size was 3 times what it should be. Also
*.param files have been excluded from the final tar.gz file.
* Migrated tensorflow image to jammy
So as to build against an older version of glibc and support more OS
versions.
* Extend new create_archive.sh to all build types
The changes on the main create_archive.sh are now on all the other
versions.
This prevents directories from being deleted when using the "move"
import option, which would otherwise delete hidden files and empty
directories.
Signed-off-by: Michael Mayer <michael@photoprism.app>
hls.light.js is a smaller version of hls.js that does not include
support for alternate audio, subtitles, CMCD, EME (DRM), and variable
substitution.
Signed-off-by: Michael Mayer <michael@photoprism.app>
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Detailed rules are in `.claude/rules/*.md` files organized by topic.
## Build Commands
Run `make help` to list all available targets. Key commands:
**Backend (Go):**
- `make build-go` — build the `photoprism` binary (develop mode)
- `make build-all` — build backend + frontend
- `go build ./...` — compile all Go packages
**Frontend (Vue 3):**
- `make build-js` — production build of the frontend
- `make watch-js` — watch mode for frontend development (Ctrl+C to stop)
- `make dep-js` — install JS dependencies only (`npm ci`). The `photoprism/develop` image and the repo `Makefile` both set `NPM_CONFIG_IGNORE_SCRIPTS=true`, so install scripts are skipped automatically; when running npm directly in an env without that default, pass `--ignore-scripts`. Rebuild native addons with `npm rebuild --ignore-scripts=false <pkg>` — a bare `npm rebuild` no-ops wherever the env default is active.
**Docker dev environment:**
- `make docker-build` — build local Docker image
- `docker compose up` — start dev environment (app at http://localhost:2342/)
- `make terminal` — open shell in dev container
## Testing
**Run all tests:**
- `make test` — runs both JS and Go tests
- `make test-go` — all Go tests (slow, ~20 min)
- `make test-js` — frontend unit tests (Vitest)
- `make test-short` — short Go tests in parallel (~5 min)
**Run targeted Go tests:**
```bash
go test ./internal/api -run 'TestFunctionName' -count=1
go test ./internal/photoprism -run 'TestMediaFile_' -count=1
go test ./internal/entity/... -count=1 -tags="slow,develop"
```
**Run targeted JS tests:**
- `make vitest-watch` — Vitest in watch mode
- `make vitest-coverage` — Vitest with coverage report
**Reset test databases before running Go tests:**
- `make reset-testdb` — clears SQLite test DBs and MariaDB testdb
Available targets: `make fmt` (everything), `make fmt-go`, `make fmt-js`, `make fmt-swag` / `make swag` (Swagger), `make lint-go`, `make lint-js`. Detailed conventions live in `.claude/rules/go-code-style.md` and `.claude/rules/frontend-rules.md`.
When creating or editing shell scripts, run `shellcheck <file>` and resolve warnings. When editing Markdown files that contain tables, format them with `npx --yes markdown-table-formatter <filename>`.
## Schema Migrations
If a change touches database schema, check migrations:
```bash
go run cmd/photoprism/photoprism.go migrations ls
go run cmd/photoprism/photoprism.go migrations run
# or via Makefile:
make migrate
```
Migration files live in `internal/entity/migrate/`.
## Architecture Overview
PhotoPrism is a self-hosted photo management app. The backend is Go, the frontend is Vue 3 + Vuetify 3, and the database is MariaDB or SQLite (via GORM).
State management uses reactive singleton modules in `src/common/` and `src/app/`, not Vuex or Pinia. Frontend code-style, formatting, testing, translation, and Playwright rules live in `.claude/rules/frontend-rules.md`.
### API Conventions
- REST API v1 base path: `/api/v1/` (configured via `conf.BaseUri()`)
- Authentication: Bearer token (`Authorization` header) or `X-Auth-Token` header
- Pagination: `count`, `offset`, `limit` parameters (default 100, max 1000)
- After adding/changing API handlers, regenerate Swagger docs: `make fmt-go swag-fmt swag`
- New routes must be registered in `internal/server/routes.go`
### Config & Flags
Verify config option names before using them:
```bash
./photoprism --help
./photoprism show config-options
./photoprism show config-yaml
```
### Verify Before Propagating
Before promoting a claim from `CLAUDE.md`, `AGENTS.md`, a memory entry, or another spec into a new rule, spec, code comment, or commit message, verify it against the current code (grep imports, list directories, read the cited file). Stale documentation silently turns into stale rules and stale specs that future sessions will trust. When the claim names a package, function, file, or framework, the cost of one grep is much smaller than the cost of repeating an error across multiple files.
### Detailed Rules
Topic-specific conventions live under `.claude/rules/` and are loaded alongside this file:
- `code-comments.md` — shared JS/Go doc comment rules (length cap, what to omit); referenced by both style files.
- `go-code-style.md`, `go-testing.md` — Go style, package boundaries, test patterns, fixtures.
description: Drives the Playwright MCP browser to exercise UI flows, verify behavior in a real Chromium, and report findings concisely. Use for any task that needs to navigate pages, click through flows, fill forms, capture console errors, check network requests, or validate UI state. Returns a short verdict + evidence so the parent context isn't filled with raw snapshots and console logs.
You are a focused UI test driver. The parent agent has delegated browser-driven work to you so its context stays clean. Your output is the only thing it sees — make it short, structured, and decision-ready.
## Loading tool schemas
The Playwright MCP tools above appear in your toolset by name but their schemas are deferred. Before you can call any of them, run `ToolSearch` once with `select:<tool_names>` to load the schemas for the specific tools you need (e.g. `select:mcp__playwright__browser_navigate,mcp__playwright__browser_snapshot,mcp__playwright__browser_click`). Don't load all of them — just the handful this task actually needs.
## Browser environment
- The MCP server runs **one shared Chromium instance** with a **persistent profile**. Cookies and `localStorage` from earlier sessions persist. Assume state may already exist — log in fresh if a flow requires a known account.
- This profile is **separate** from the user's real Chrome. You won't see their personal logins or extensions.
- There is no parallelism — only you should be driving the browser during your run.
## Workflow
1. **Plan first.** Decide the minimum flow that proves or disproves the parent's question. Don't explore; execute.
2. **Run.** Navigate, interact, observe. Prefer `browser_snapshot` (accessibility tree) over `browser_take_screenshot` — it's much smaller and works for assertions. Reach for screenshots only when the parent explicitly wants visual evidence.
3. **Capture signals.** Pull `browser_console_messages` and, if relevant, `browser_network_requests` near the end of the flow — they're often the actual answer.
4. **Clean up before returning.** Call `browser_close` so the next subagent starts with no open tabs. The persistent profile keeps cookies, but tabs/state shouldn't leak across runs.
## Reporting format
Default to **under 300 words** unless the parent asked for more. Structure:
- **Verdict:** one sentence — pass / fail / partial, and what was tested.
- **Evidence:** 3–6 bullet points with the concrete observations (URLs visited, elements clicked, console errors verbatim, network failures with status codes). Quote error messages exactly; don't paraphrase.
- **Notes (optional):** anything the parent should know that wasn't asked for but matters (e.g. "noticed an unrelated 404 on `/api/foo`").
Don't paste full snapshots, full console logs, or screenshot data unless the parent specifically requested them. The parent does not need to see your tool-call play-by-play — just the conclusion and the evidence supporting it.
## What not to do
- Don't open new browsers, install Playwright, or modify the MCP server config.
- Don't edit application code unless the parent explicitly told you to. Your job is to test what's there.
- Don't run long-form Go/JS test suites (that's `make test` territory) — you're the manual-QA-in-a-browser agent, not a CI runner.
- Don't summarize what you just did at the end ("I navigated to X, then clicked Y, then..."). The verdict + evidence is the summary.
- Respect precedence: `options.yml` overrides CLI/env values, which override defaults.
- Adding a new option: update `internal/config/options.go` (yaml/flag tags), register in `internal/config/flags.go`, expose a getter, surface it in `*config.Report()`, and write generated values back to `options.yml`. Use `CliTestContext` in `internal/config/test.go` to exercise new flags.
- Adding a `customize.FeatureSettings` flag: a new field defaults to `true` via reflection (`features_default.go`) and is operator-disableable through `PHOTOPRISM_DISABLE_FEATURES` — no new CLI option needed. It cascades: update the full-struct literals in `internal/config/customize/{acl,scope}_test.go` and `internal/config/client_config_test.go` (the longest field name re-aligns every literal via gofmt), and `testdata/settings.yml` self-updates via `TestSettings_Save`. If the flag is only meaningful for accounts/roles, gate it per-session in `customize.Settings.ApplyACL` / `ApplyScope` (e.g. `Account`/`AppPasswords` require `ResourcePassword`/`ActionUpdate`); that shapes the Web UI client config only — enforce server-side behavior on the global flag via a `Config.DisableX()` helper.
- Identify an app-password credential by its session, not its token: `(*entity.Session).IsApplication()` (auth provider `application`) covers every grant that mints one (`password`/`session`/`cli`). The token format and grant type vary, so don't gate on `rnd.IsAppPassword` or `GrantType`.
- For `options.yml` writes, prefer config-owned persistence helpers: `Config.SaveOptionsPatch(...)` for generic merges, `Config.SaveClusterOptionsUpdate(...)` for cluster-managed metadata.
- Use `pkg/fs.ConfigFilePath` for config filenames so existing `.yml` files stay valid and new installs can adopt `.yaml` transparently.
- Use the public accessors on `*config.Config` (e.g. `JWKSUrl()`, `SetJWKSUrl()`) instead of mutating `Config.Options()` directly; reserve raw option tweaks for test fixtures.
- New metadata sources (e.g. `SrcOllama`, `SrcOpenAI`) must be defined in both `internal/entity/src.go` and the frontend lookup tables (`frontend/src/common/util.js`).
- Config init order: load `options.yml` (`c.initSettings()`), run `EarlyExt().InitEarly(c)`, connect/register the DB, then `Ext().Init(c)`.
- Database helpers: reuse `conf.Db()` / `conf.Database*()`, avoid GORM `WithContext`, quote MySQL identifiers, and reject unsupported drivers early.
## Handler Conventions
- Reuse limiter stacks (`limiter.Auth`, `limiter.Login`) and `limiter.AbortJSON` for 429s. Lean on `api.ClientIP`, `header.BearerToken`, and `Abort*` helpers.
- Compare secrets with constant-time checks; set `Cache-Control: no-store` on sensitive responses.
- Register routes in `internal/server/routes.go`. New list endpoints default `count=100` (max 1000) and `offset≥0`; document parameters explicitly.
- Set portal mode via `PHOTOPRISM_NODE_ROLE=portal` plus `PHOTOPRISM_JOIN_TOKEN` when needed.
## API Shape Checklist
When renaming or adding fields:
- Field casing: **TitleCase** (`UUID`, `Name`, `SiteUrl`) for fields backed by a DB entity (mirror the entity/model), **camelCase** (`storageNamespace`, `redirectUri`) for generated/artificial payloads (client config, session, action/RPC bodies). A filtered/computed entity projection stays TitleCase; an action payload stays camelCase but MAY TitleCase its single entity-identity field (e.g. `UUID`). See `specs/common/field-casing.md`.
- Update DTOs in `internal/service/cluster/response.go` and any mappers.
- Update handlers and regenerate Swagger: `make fmt-go swag-fmt swag`.
- Update tests (search/replace old field names) and examples in `specs/`.
- Quick grep: `rg -n 'oldField|newField' -S` across code, tests, and specs.
## Testing Helpers
- Isolate config paths with `t.TempDir()`; reuse `NewConfig`, `CliTestContext`, and `NewApiTest()` harnesses.
- Authenticate via `AuthenticateAdmin`, `AuthenticateUser`, or `OAuthToken`. Toggle auth with `conf.SetAuthMode(config.AuthModePasswd)`.
- Prefer OAuth client tokens over non-admin fixtures for negative permission checks.
## Roles & ACL
- Map roles via the shared tables: users through `acl.ParseRole(s)` / `acl.UserRoles[...]`, clients through `acl.ClientRoles[...]`.
- Treat `RoleAliasNone` ("none") and an empty string as `RoleNone`; default unknown client roles to `RoleClient`.
- Build CLI role help from the registered role map (never hand-maintained literals) so each edition lists exactly the roles it accepts: `commands.UserRoleUsageFor(<map>)` / `Roles.CliUsageString()`, passing CE `acl.UserRoles` or an edition's own static `auth.UserRoles` (reference the edition map directly, not the runtime-reassigned `acl.UserRoles`, to avoid the init-order trap; Portal's map includes `cluster_admin`). For federatable / cluster-instance contexts (LDAP, OIDC group→role, cluster grants) use `acl.ClusterInstanceRolesCliUsageString()` — it excludes `cluster_admin`/`visitor`. `pkg/txt.JoinOr` renders the "a, b, or c" style.
- For JWT/client scope checks, use the shared helpers (`acl.ScopePermits` / `acl.ScopeAttrPermits`).
- Path hint: when the project path is `/go/src/github.com/photoprism/photoprism`*and*`/.dockerenv` is absent, assume you are on the host with a bind mount.
### Host Mode
- Build local dev image (once): `make docker-build`
- Start services: `docker compose up` (add `-d` for background)
- Execute a single command in the app container: `docker compose exec photoprism <command>`
- Run as non-root to avoid root-owned files: `docker compose exec -u "$(id -u):$(id -g)" photoprism <command>`
- Open a terminal session: `make terminal`
- Stop everything: `docker compose --profile=all down --remove-orphans` (`make down`)
### Container Mode
- Install deps: `make dep`
- Build frontend/backend: `make build-js` and `make build-go`
- Watch frontend changes: `make watch-js`
- Start PhotoPrism server: `./photoprism start`
- HTTP: http://localhost:2342/
- HTTPS: https://app.localssl.dev/ (via Traefik, if running)
- Admin Login: Default credentials are `admin` / `photoprism`; check `compose.yaml` for `PHOTOPRISM_ADMIN_USER` and `PHOTOPRISM_ADMIN_PASSWORD` if they differ.
- Do not use the Docker CLI inside the container; starting/stopping services requires host Docker access.
### Operating Systems & Architectures
- Guides and command examples assume Linux/Unix shell on 64-bit AMD64 or ARM64.
- For Windows-specifics, see the Developer Guide FAQ: https://docs.photoprism.app/developer-guide/faq/#can-your-development-environment-be-used-under-windows
### CLI Binary Names
The CLI name is `photoprism` in production and public docs. Other binary names (`photoprism-plus`, `photoprism-pro`, `photoprism-portal`) are only used in development builds for side-by-side comparisons.
## Container Image Builds
- **Pin the dev `Dockerfile` `FROM` to a dated `photoprism/develop` tag, never the floating codename.** Use `photoprism/develop:<YYMMDD>-<codename>` (e.g. `photoprism/develop:260520-resolute`), not `photoprism/develop:resolute` / `:latest` / `:ubuntu`. The dated tag pins to a known-good build so a drive-by `docker pull` doesn't silently swap in a fresh base mid-session, and bumping the date in a commit gives developers + reviewers a visible signal that a new base image is available. The `buildx-multi.sh` script publishes both `:<codename>` and `:<YYMMDD>-<codename>` on every build — the dated one is the durable pointer. When you bump the base image, also update `Dockerfile`, the host `compose.yaml` (if it references the tag explicitly), and any docs that quote the current dev image.
- **Never mix Debian and Ubuntu `apt` repositories in the same image:**
- Don't add a Debian source to an Ubuntu base (or vice versa) to install a single missing package — the transitive deps drift, apt's solver pulls newer libraries from the foreign distro, and other build steps in the same `RUN` (e.g. `install-libheif.sh` running `apt-get install libavcodec-dev`) silently link against the wrong soname.
- Symptoms surface much later as `dlopen: libfoo.so.N: cannot open shared object file` at image runtime, with the binary referencing a soname that exists only in the foreign distro.
- If a package isn't available in the host distro's repos, prefer (a) a same-distro PPA / backports source, (b) a vendor-supplied .deb (e.g. Google Chrome from `dl.google.com`), or (c) a from-source build pinned to a known version.
- Keep bootstrap decoupled: avoid importing `internal/service/cluster/node/*` from `internal/config` or the cluster root; nodes talk to the Portal over HTTP(S) using constants from `internal/service/cluster/const.go`.
- Bootstrap refreshes node OAuth credentials on 401/403 (rotate secret + retry, info-level log). If the secret file can't be written, the rotated value stays cached in memory so the current process continues.
- Portal validation accepts HTTP advertise URLs only for loopback or cluster-internal domains (`*.svc`, `*.cluster.local`, `*.internal`); everything else must use HTTPS.
- Theme endpoint: `GET /api/v1/cluster/theme` streams a zip from `conf.ThemePath()`; reinstall only when `app.js` is missing and always use the header helpers in `pkg/http/header`.
- Registration flow: send `rotate=true` only for MySQL/MariaDB nodes without credentials; treat 401/403/404 as terminal; include `ClientID` + `ClientSecret` when renaming an existing node; persist only newly generated secrets or DB settings.
### Registry & DTOs
- Use the client-backed registry (`NewClientRegistryWithConfig`) — the file-backed version is legacy.
- Nodes are keyed by UUID v7 (`/api/v1/cluster/nodes/{uuid}`); registry interface is UUID-first (`Get`, `FindByNodeUUID`, `FindByClientID`, `RotateSecret`, `DeleteAllByUUID`). CLI lookups resolve `uuid → ClientID → name`.
- DTOs normalize `Database.{Name,User,Driver,RotatedAt}`; `ClientSecret` is exposed only during creation/rotation. `nodes rm --all-ids` cleans duplicate client rows. `ClientData` no longer stores `NodeUUID`.
- Admin responses may include `AdvertiseUrl`/`Database`; client/user sessions stay redacted.
- Registry files live under `conf.PortalConfigPath()/nodes/` (mode 0600).
### Provisioner & DSN
- Database/user names use UUID-based HMACs (`<prefix>d<hmac11>`, `<prefix>u<hmac11>`; prefix defaults to `cluster_`).
- `BuildDSN` accepts a `driver` but falls back to MySQL format with a warning when unsupported. For Postgres, extend `BuildDSN` and `provisioner.DatabaseDriver` handling, add validations, and return `driver=postgres` consistently in API and CLI output.
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", token)
```
- Admins see `AdvertiseUrl` and `Database`; client/user sessions don't. `SiteUrl` is safe for all roles. Client config includes `storageNamespace` (SHA-256 of `SiteUrl`) for browser storage scoping.
### Preflight Checklist
- `go build ./...`
- `make fmt-go swag-fmt swag`
- `go test ./internal/service/cluster/registry -count=1`
- `go test ./internal/api -run 'Cluster' -count=1`
- `go test ./internal/commands -run 'ClusterRegister|ClusterNodesRotate' -count=1`
- Tooling: `make swag` may fetch modules — confirm network access before running.
A doc comment is **required** for every function (including unexported helpers), as well as for every non-trivial Vue `methods:` / `computed:` / watcher:
- Keep comments **compact** and default to one line for "what" in the format `// Name does X.`. Skip trivial getters (`isOpen: () => this.open`).
- Add 1-2 follow-up lines (`// …`) **only** if the "why" is non-obvious: a hidden invariant, a workaround that would otherwise be undone by a future cleanup, a contract a reader can't infer from the code. If readers can infer the "why" from the function body or a nearby line, then omit it.
- Multi-paragraph explanations belong in `specs/`, package `README.md` files, or GitHub issues — never in the source itself.
Doc comments for packages and exported identifiers must be complete sentences that begin with the name of the thing being described and end with a period. For short examples in comments, indent code instead of using backticks.
Use US English spelling in all code comments (`parameterized`, `behavior`, `color`, `serialize`, `normalize`, `optimize`, …) — not the British `-ised`/`-our`/`-re` variants.
> **Don't include in code comments:** Issue / PR numbers, "previously…" history, alternatives considered, what the function used to do, references to old commits, names of subsequent reviewers, or any narrative that names the change rather than the steady-state behavior. That context belongs in commit messages, specs, or handover notes.
Use concise, imperative subjects with a one-word prefix indicating the scope or topic:
- `Config: Add tests for "darktable-cli" path detection`
If the commit relates to specific issues or pull requests, reference their IDs in the message:
- `Docker: Use two stage build to reduce image size #123 #5632`
Commit messages must not exceed 80 characters in length.
Do not add `Co-Authored-By: Claude …` trailers (or any other AI-authorship trailer) to commit messages.
## GitHub Issues
Issue titles MUST be concise, use the imperative mood, and start with a single capitalized prefix followed by a colon and a space, e.g. `Search: Add filter for RAW image formats`.
Issue descriptions MUST begin with a one-sentence **User Story** in the format: `**As a <role>, I want <goal>, so that <outcome>.**`
Use level-3 Markdown headings for sections within issue descriptions, for example `### Acceptance Criteria`.
Follow the User Story with a clear summary of the expected behavior, rationale, technical considerations, and constraints.
Descriptions MUST conclude with a checklist of **Acceptance Criteria**:
- Use GitHub checklist formatting: `- [ ]`
- Criteria MUST be clear, testable, and unambiguous.
- Each item MUST use one of the following requirement-level keywords:
- `MUST` — required for the issue to be considered complete
- `SHOULD` — strongly recommended but not strictly required
- `MAY` — optional enhancement
- Keep the checklist current: once the work for a criterion is implemented **and verified**, mark it done (`- [x]`).
- Leave items that are unverified, not yet implemented, or skipped optional (`MAY`) enhancements unchecked.
- An issue is complete only when every `MUST` is checked; never tick a box on the strength of a plan alone or an unrun test.
- When referencing an issue from a commit that fulfills some of its criteria, update the matching boxes first.
> Agents MUST create, edit, close, reopen, relabel, or otherwise modify GitHub issues only when explicitly requested by the user.
The repo's issue templates use the new GitHub `type:` property (`Bug`, `Feature`) instead of `bug`/`idea` labels. `gh issue create` does not yet accept a `--type` flag, so when filing issues programmatically use `--label` only and tell the user to set the issue type via the web UI.
## Specifications & Documentation
- Document headings use a **Chicago-style title case**, with additional code- and path-aware normalization rules (see below). Always spell the product name as `PhotoPrism`.
- When writing CLI examples or scripts, place option flags before positional arguments unless the command requires a different order.
- Use RFC 3339 UTC timestamps in request and response examples, and valid ID, UID and UUID examples in docs and tests.
- Technical specifications in the nested `specs/` subrepository may not be present in every clone or environment. Do not add `Makefile` targets in the main project that depend on `specs/` paths.
- Auto-generated configuration and command references live under `specs/generated/`. Agents MUST NOT read, analyze, or modify anything in this directory.
- Nested Git repositories may appear to be ignored; if so, change directories before staging or committing updates.
- **Never reference `specs/` paths from public artifacts** — issue bodies, PR descriptions, package READMEs (`frontend/README.md`, `internal/*/README.md`, etc.), top-level `CODEMAP.md`/`GLOSSARY.md`, code comments outside `specs/`. External readers see a 404 and the private subrepo's existence is leaked. Hints in `AGENTS.md` and `CLAUDE.md` files are the documented exception. Quick check: `grep -n "specs/" <file>` should return no matches before saving any public-facing file.
> **Title Case** rules (Chicago-style headline capitalization, with code- and path-aware normalization):
> - Capitalize the first word, the first word after a colon, dash, or end punctuation, and all major words, including the second part of a hyphenated major word.
> - Lowercase only articles, short conjunctions, and short prepositions of three letters or fewer when they are not in one of those positions.
> - Preserve known acronyms (for example, API, CLI, HTTP, JSON) and slash-separated acronym groups (for example, CSV/TSV) as uppercase.
> - Preserve RFC 2119 / RFC 8174 normative keywords (MUST, SHOULD, MAY, SHALL, REQUIRED, RECOMMENDED, OPTIONAL) as uppercase when used in their normative sense.
> - Preserve inline code spans (`` `foo` ``), file paths (e.g. `docs/foo-bar.md`), and slash commands (e.g. `/grill-me`) verbatim; do not recase their contents.
> - Use `&` instead of `And`/`Or` in headings.
> Refresh the `**Last Updated:**` date at the top of documents whenever you make changes to their contents, using the format `January 20, 2026` (without time); leave it as-is for simple formatting or whitespace-only edits.
- **Comments:** Follow the code comment rules in `code-comments.md`.
- **Tests:** Test new JS functions (including helpers) and new Vue components whenever practical; update existing tests when behavior changes. When a unit test is impractical (DOM-heavy flows, third-party widget integration), the doc comment is still mandatory — it's the minimum bar.
- **State:** Shared state lives in reactive singleton modules under `src/common/` and `src/app/` (e.g., `app/session.js`, `common/config.js`, `common/clipboard.js`, `common/log.js`) that export a `reactive()` / `ref()` object directly; components access them via `import` or via the globally-installed `$config` / `$session` plugins. Do not introduce Vuex, Pinia, or new ad-hoc stores — extend an existing singleton or add a new one alongside its peers in `common/` or `app/`.
- **Vue/Vuetify:** Use the Options API in Vue components (consistent with the rest of the codebase); do not introduce Composition API or `<script setup>`.
- **TypeScript:** Do not introduce TypeScript. The frontend is a pure JS + Vue SFC codebase: no `.ts` files, no `tsconfig.json`, no `<script lang="ts">` blocks. JSDoc type annotations in comments are fine; full TS migrations are out of scope.
## Frontend Formatting
- ESLint + Prettier own formatting. After edits run `make fmt-js` (or `npm run fmt` inside `frontend/`) and `make lint-js` to verify; `frontend/eslint.config.mjs` is the flat-config source of truth.
- The dev container preinstalls `eslint` and `prettier` on the global `PATH` at the same version `frontend/package.json` pins (`eslint --version` should match the `eslint` entry in `frontend/package.json`). Invoke them directly (e.g. `eslint --fix tests/`) from `frontend/` — no need for `npx`, which adds a spawn step and an extra resolution layer.
- Prettier reflow is **not** part of `make fmt-js` / `eslint --fix` — the `prettier/prettier` rule is set to `"off"` so intentional newlines (multi-line method chains, vertical predicate lists) are preserved. Run `prettier --write <file>` explicitly when a full reflow is wanted; do not run it blanket across `src/` or `tests/`.
- Prettier uses `printWidth: 160`, double quotes, semicolons, `trailingComma: "es5"`, and `proseWrap: "never"` (see `frontend/.prettierrc.json`). Do not hand-wrap long lines — let Prettier decide. CSS/SCSS use `tabWidth: 4`.
- The repo-root `.editorconfig` covers indentation and newline style; don't override it locally.
- Vue SFC block order is `<template>` → `<script>` → `<style>`; keep it consistent with existing components.
## Frontend Dependencies & Pins
- `frontend/README.md` is the canonical doc for pin rationale, the `overrides` layer, ESM-only upgrade blockers, and the orphan-audit pattern — read it before bumping any non-caret pin or adding/removing a top-level dep.
- **Pins are intentional.** When a version has no caret (e.g., `"axios": "1.16.1"`, `"vuetify": "3.12.2"`), check `frontend/README.md` and `git log -p -S "<pkg>" -- frontend/package.json` for the reason before changing it.
- npm is a workspace; run `npm install --ignore-scripts --no-audit --no-fund --no-update-notifier` from the **repo root** (not `frontend/`) so the root `package-lock.json` updates. After dep changes also run `make audit`, `make build-js`, `make test-js`, and `make notice`.
- Before adding a new dep or removing one as "unused", run `rg -nF "<pkg>" frontend ...` plus `npm ls <pkg> --all` to confirm there's no transitive consumer or peer-dep. Recent precedents: `postcss-url`, `@vitejs/plugin-react`, `cheerio`, `@testing-library/react`, `vite-tsconfig-paths` (all true orphans removed once consumer left).
## Frontend Linting & Test Entry Points
- Follow the lint/format scripts in `frontend/package.json`; all added JS, Vue, and tests must conform.
- Unit tests (Vitest): `make test-js`, `make vitest-watch`, `make vitest-coverage`. Acceptance: `acceptance-*` targets in the root `Makefile`.
- **Always invoke Vitest through the npm/make wrapper, never bare `npx vitest run`.**`frontend/package.json`'s `test` script wraps the call in `cross-env TZ=UTC BUILD_ENV=development NODE_ENV=development BABEL_ENV=test`. Without those env vars ~50 component tests (Vuetify renders, chip-selector, login, location-input, batch-edit, people-tab, lightbox `toggleSidebar`, etc.) and TZ-sensitive date tests fail spuriously — the failures look real but only reproduce in the unwrapped invocation. Do not compare a "failed N, passed M" report from bare `npx vitest run` against a `make test-js` baseline. For ad-hoc filtering on a single file, mirror the env explicitly: `(cd frontend && TZ=UTC BUILD_ENV=development NODE_ENV=development BABEL_ENV=test npx vitest run <path>)`.
Always return to repo root before `make acceptance-sqlite-stop`.
## Frontend Test Gotchas
- Hidden-route UI checks under `/library/hidden` or `/portal/hidden` require both `files.file_error` and `photos.photo_quality = -1`; `file_error` alone will not surface the row.
## Playwright MCP Usage
- Endpoint `http://localhost:2342/`; logins at `/library/login` (CE/Plus/Pro) and `/portal/login` (Portal). Use local compose admin credentials; if login fails, inspect the active compose env.
- Viewports: desktop `1280x900`; mobile uses the mobile Playwright server at `375x667`. Close the browser tab after scripted interactions.
- Prefer waits over sleeps; click only visible/enabled elements; use role/label/text selectors (not XPath).
- Screenshots: small and reproducible — JPEG, visible viewport, deterministic `.local/screenshots/<case>/<step>__<viewport>.jpg` names, no large inline screenshots.
- If `npx` fetches an MCP server at runtime, add `--yes` or preinstall to avoid prompts.
- Delegate to the `ui-tester` subagent for any flow with more than ~2 browser steps (login + navigate + assert, multi-step forms, regression sweeps). Brief it with the URL, credentials, exact steps, and the verdict format you want back; ask for a short report so raw snapshots and console dumps stay out of the parent context. Drive Playwright MCP inline only for one-shot checks (single navigate, single screenshot).
## Frontend Focus Management
- Dialogs must follow the shared pattern in `frontend/src/common/README.md`: expose `ref="dialog"` on `<v-dialog>`, call `$view.enter/leave` in `@after-enter` / `@after-leave`, and avoid positive `tabindex`.
- Persistent dialogs (`persistent` prop) must handle Escape via `@keydown.esc.exact` to suppress Vuetify's rejection animation; keep other shortcuts on `@keyup` so inner inputs can cancel first.
- Global shortcuts go through `onShortCut(ev)` in `common/view.js`, which only forwards Escape and `ctrl`/`meta` combos — don't rely on it for arbitrary keys.
- When a dialog opens nested menus (e.g., combobox suggestions), confirm they work with the global trap; see the README for troubleshooting.
## Frontend Translations
- Never hardcode locale strings in templates or scripts — every user-visible string MUST go through `$gettext` / `T` so it appears in `frontend/src/locales/translations.pot`.
- **Exception — standardized technical identifiers stay untranslated.** Render protocol/acronym/identifier field labels and option values as literal strings, not via `$gettext`: e.g. `Client ID`, `Client Credentials`, `OIDC`, `UUID`, `Node UUID`. Translating them adds catalog noise and risks ambiguous renderings (e.g. "Client" → German "Kunde"). Common English field names like `Site URL` or `Advertise URL` stay translated.
- **Share role/provider labels, not the selectable lists.** Display names live once in `frontend/src/options/auth.js` (`Roles()` / `Providers()` maps, with `RoleOptions(keys, labelKey)` / `ProviderOptions(keys, labelKey)` builders). Private editions (`plus`/`pro`/`portal`) import these and pass their own key list — the selectable sets legitimately differ per edition (cluster_admin, LDAP/AD, reduced Plus set) but the labels must not be re-listed.
- Extraction source of truth: root `make gettext-extract` (via `scripts/gettext-extract.sh`), which scans `frontend/src` plus available overlays in `plus/frontend`, `pro/frontend`, `portal/frontend`. Feature PRs do **not** commit the resulting `.pot`/`.po` churn — Weblate re-extracts on its refresh cycle (see `specs/frontend/translations.md`); a dedicated "Regenerate translation catalogs" commit is the exception.
- Catalog integrity gate: run `make gettext-lint` (`scripts/gettext-lint.mjs`) after any `.po`/`.pot` edit. It flags placeholder-set mismatches between `msgid` and `msgstr` (frontend `%{name}`, backend printf verbs), edge-whitespace drift, and `msgfmt -c` c-format fatals — defects that silently break variable substitution at runtime. Leading/trailing/internal whitespace inside a `msgid` is itself runtime-inert (`vue3-gettext` trims + collapses keys on load and lookup), so trailing-space findings are cleanliness, not correctness.
- Trimming a source string's trailing space must stay consistent across template + `.po` + `.pot`: trim the literal, then `make gettext-extract`. Caveat: `msgmerge --no-fuzzy-matching` treats the trimmed `msgid` as new, blanks its `msgstr`, and moves the old translation to an obsolete `#~` block — so carry that translation onto the active trimmed entry (collapse any folded multi-line `#~ msgstr`) before `make gettext-compile`, or every locale renders untranslated. This only fills `msgstr`, so Weblate merges it cleanly.
## Web Templates & Shared Assets
- HTML entrypoints live in `assets/templates/`: `index.gohtml`, `app.gohtml`, `app.js.gohtml`, `splash.gohtml`. `assets/static/js/browser-check.js` runs capability checks before the main bundle; keep it loaded before the bundle script in `app.js.gohtml` and don't add `defer`/`async` to the bundle tag unless you reintroduce a guarded loader.
- OIDC login completion bridges through `assets/templates/auth.gohtml`, writing the session into namespaced browser storage — must stay aligned with `frontend/src/common/session.js`, `frontend/src/common/storage.js`, and the login-form toggle in `frontend/src/page/auth/login.vue`.
- When touching session bootstrap, verify `session.js` resolves `storageNamespace` from the real client-config shape (`window.__CONFIG__` / `config.values`), not just mocks. Add a focused test that would fail if restore fell back to `pp:root:`.
- The loader partial is reused in `pro/`, `plus/`, and `portal/assets/templates/index.gohtml`; verify they still include it whenever `app.js.gohtml` or bundle loading changes.
- Splash styles: `frontend/src/css/splash.css` — add new splash elements there for cross-edition consistency.
- Browser baseline: Safari 13 / iOS 13 or current Chrome, Edge, Firefox.
- **Comments:** Follow the code comment rules in `code-comments.md`.
- **Packages:** Every Go package must contain a `<package>.go` file in its root (e.g. `internal/auth/jwt/jwt.go`) with the standard license header and a short package description comment.
- **Format:** Go is formatted by `gofmt` with tabs. Do not hand-format indentation. After edits run `make fmt-go` (gofmt + goimports).
- **Linting:** Run `make lint-go` (`golangci-lint`) after Go changes; prefer `golangci-lint run ./internal/<pkg>/...` for focused edits.
## Package Boundaries
- Code in `pkg/*` MUST NOT import from `internal/*`. If you need config/entity/DB access, put new code under `internal/` instead.
## GORM Field Naming
When adding struct fields with uppercase abbreviations (e.g. `LabelNSFW`, `UserID`, `URLHash`), set an explicit `gorm:"column:<name>"` tag so column names stay consistent (`label_nsfw`, `user_id`, `url_hash` instead of split-letter variants).
## Filesystem Permissions & io/fs Aliasing
- Always use shared permission variables from `pkg/fs` when creating files/directories:
- Do not pass stdlib `io/fs` flags to functions expecting permission bits. When importing the stdlib package, alias it to avoid collisions: `iofs "io/fs"` or `gofs "io/fs"`.
- Prefer `filepath.Join` for filesystem paths; reserve `path.Join` for URL paths. For slash-based logical paths stored in DB/config/API payloads (e.g. folder album paths), normalize with `clean.SlashPath(...)` instead of ad-hoc `strings.ReplaceAll(..., "\\", "/")` + trim logic.
## Logging
- Use the shared logger (`event.Log`) via the package-level `log` variable (see `internal/auth/jwt/logger.go`) instead of direct `fmt.Print*` or ad-hoc loggers.
- Terminology: in human-readable log text, prefer canonical runtime terms (`instance`, `service`) and reserve `node` for contract-bound names (`/cluster/nodes`, `Node*`, `PHOTOPRISM_NODE_*`).
- Audit outcomes: import `github.com/photoprism/photoprism/pkg/log/status` and end every `event.Audit*` slice with a single outcome token such as `status.Succeeded`, `status.Failed`, `status.Denied`. When a sanitized error string should be the outcome, call `status.Error(err)` instead of manually passing `clean.Error(err)`.
- Every new Go function (including unexported helpers) must have focused coverage in a sibling `*_test.go`. Refactors count: each new helper needs its own `Test<Name>` with at least a Success and an error/InvalidRequest case — don't rely on the old test covering the new path.
- Before reporting a change done, grep your diff for `^func ` additions and confirm each has a matching `Test*`. Swagger or route regeneration is not a substitute — Swagger documents shape, tests prove behavior.
## Go Testing Patterns
- Tests live next to sources (`<file>_test.go`); group cases with `t.Run(...)` using **PascalCase** names (`Success`, `InvalidRequest`). Consecutive subtests inside the same `Test*` function are written without blank lines between them so the cases read as a compact table; reserve blank lines for separating distinct setup blocks.
- Do not run multiple test commands in parallel — suites share fixtures, temp assets, and DB state.
- Keep Go scratch work inside `internal/...` (Go refuses `internal/` imports from `/tmp`).
- Prefer focused runs: `go test ./internal/<pkg> -run <Name> -count=1`. Avoid `./...` unless needed; heavy packages (`internal/entity`, `internal/photoprism`) take 30–120s on first run.
- Media helpers (fast): `go test ./pkg/media/... -count=1`
- Thumbnails (libvips, moderate): `go test ./internal/thumb/... -count=1`
- FFmpeg builders (moderate): `go test ./internal/ffmpeg -run 'Remux|Transcode|Extract' -count=1`
### Test Config Helpers
- Default to `config.NewMinimalTestConfig(t.TempDir())` for FS/config scaffolding, or `config.NewMinimalTestConfigWithDb("<name>", t.TempDir())` for a fresh SQLite schema.
- Reserve `config.TestConfig()` for tests that truly need the fully seeded fixture snapshot (runs `InitializeTestData()`, wipes `storage/testdata`).
- Config helpers auto-discover `assets/`; don't set `PHOTOPRISM_ASSETS_PATH` in `init()`. Hub traffic is disabled by default; re-enable with `PHOTOPRISM_TEST_HUB=test`.
- `PhotoFixtures.Get()` etc. return value copies — re-query via `entity.FindPhoto(fixture)` when you need the DB row.
- New persistent IDs: `rnd.GenerateUID(entity.PhotoUID|FileUID|LabelUID|ClientUID|…)`; node UUIDs use `rnd.UUIDv7()` and `node.uuid` is required in responses.
- Use `entity.Values` (not raw `map[string]interface{}`) for DB updates. Reuse shared `Example*` constants for illustrative credentials (see `internal/service/cluster/examples.go`).
### CLI Testing Gotchas
- `urfave/cli` calls `os.Exit` on `cli.Exit(...)`; use `RunWithTestContext` (in `internal/commands/commands_test.go`) or invoke `cmd.Action(ctx)` directly and check `err.(cli.ExitCoder).ExitCode()`.
- Non-interactive: set `PHOTOPRISM_CLI=noninteractive` and/or pass `--yes`.
- SQLite DSN from `NewTestConfig("<pkg>")` is a per-suite path like `.<pkg>.db` — don't assert empty.
- Reuse shared flag helpers (`DryRunFlag(...)`, `YesFlag()`) for new CLI flags.
### FFmpeg & Hardware Gating
- Gate GPU/HW encoder integrations with `PHOTOPRISM_FFMPEG_ENCODER`; CI skips them by default.
- Negative paths (missing ffmpeg, unwritable dest) must stay fast and always run. Prefer command-string assertions when hardware is unavailable.
### API/CLI Test Pitfalls
- Register `CreateSession(router)` once per test router — duplicates panic.
- Don't invoke `start` or emit signals in unit tests; some commands defer `conf.Shutdown()` and close the DB.
- MariaDB iteration: `mariadb -D photoprism` for ad-hoc SQL without rebuilding Go.
- ImportWorker may skip files if an identical one already exists (duplicate detection). Use unique copies or assert DB rows after ensuring a non-duplicate destination.
- Mixed roots: keep `SamplesPath()/ImportPath()/OriginalsPath()` consistent so `RelatedFiles` and `AllowExt` behave as expected.
- `IndexOptions*` helpers require a `*config.Config`; pass the active config (or `config.NewMinimalTestConfig(t.TempDir())` in unit tests) so face/label/NSFW scheduling matches the current run.
- Folder albums use path-first lookup/update (`album_path`) to avoid slug collisions for emoji child paths.
- Label/label-search logic should reuse `entity.FindLabels(...)`, `entity.FindLabelIDs(...)`, and `entity.LabelSlugs(...)` for homophone-aware exact-name matching — avoid ad-hoc slug SQL in search code.
- Vision worker scheduling is controlled via `VisionSchedule` / `VisionFilter` and `Run` in `vision.yml`. Use `vision.FilterModels` and `entity.Photo.ShouldGenerateLabels/Caption` to decide when work is required before loading media files.
**Code anchors:** CLI flags/examples in `internal/commands/download.go`; core impl in `internal/commands/download_impl.go`; yt-dlp helpers in `internal/photoprism/dl/*` (`options.go`, `info.go`, `file.go`, `meta.go`); importer entry in `internal/photoprism/get/import.go`; import options in `internal/photoprism/import_options.go`.
**Fast test runs:**
- yt-dlp package: `go test ./internal/photoprism/dl -run 'Options|Created|PostprocessorArgs' -count=1`
- CLI command: `go test ./internal/commands -run 'DownloadImpl|HelpFlags' -count=1`
**FFmpeg-less tests:** set `c.Options().FFmpegBin = "/bin/false"` and `c.Settings().Index.Convert = false` to avoid ffmpeg dependencies when not validating remux.
**Stubbing yt-dlp (no network):** use a shell script that prints minimal JSON for `--dump-single-json` and creates a file + prints its path when `--print` is requested. Harness env vars: `YTDLP_ARGS_LOG` (append final args for assertion), `YTDLP_OUTPUT_FILE` (absolute file path to create for `--print`), `YTDLP_DUMMY_CONTENT` (file contents to avoid importer duplicate detection between tests).
- File method: yt-dlp writes files; we pass `--postprocessor-args 'ffmpeg:-metadata creation_time=<RFC3339>'` so imports get `Created` even without local remux.
- Default policy `auto`; use `always` for the most complete metadata.
- CLI defaults: `photoprism dl` defaults to `--method pipe` and `--impersonate firefox`; pass `-i none` to disable impersonation.
- If `git status` shows unexpected changes, assume a human might be editing; ask before using reset commands like `git checkout` or `git reset`.
- Do not run `git config` (global or repo-level); changing Git configuration is prohibited for agents. Nested subrepos (e.g. `specs/`) may lack a configured committer identity — pass `-c user.email=… -c user.name=…` to the specific `git commit` invocation rather than configuring the repo.
- Do not run destructive commands against production data. Prefer ephemeral volumes and test fixtures for acceptance tests. The destructive CLI commands `photoprism reset`, `users reset`, `auth reset`, and `audit reset` require explicit `--yes`; never invoke them in examples or scripts without a backup warning.
- Never commit secrets, local configurations, or cache files. Use environment variables or a local `.env`. Ensure `.env`, `.config`, `.local`, `.codex`, and `.gocache` are in `.gitignore` and `.dockerignore`.
- Prefer existing caches, workers, and batching strategies in code and `Makefile`. Consider memory/CPU impact of changes; only suggest benchmarks or profiling when justified.
- Regenerate `NOTICE` files with `make notice` when dependencies change (e.g. `go.mod`, `go.sum`, `package-lock.json`). Do not edit `NOTICE` or `frontend/NOTICE` manually.
> If anything in this file conflicts with the `Makefile` or Sources of Truth, **ask** for clarification before proceeding.
## File I/O — Overwrite Policy (force semantics)
- Default is safety-first: callers must not overwrite non-empty destination files unless they opt-in with `force=true`. Replacing empty destinations is allowed without `force`.
- Open destinations with `O_WRONLY|O_CREATE|O_TRUNC` to avoid trailing bytes when overwriting; use `O_EXCL` when callers must detect collisions.
- Where this lives: `internal/photoprism/mediafile.go` (`MediaFile.Copy/Move`), `pkg/fs/copy.go`, `pkg/fs/move.go`.
- When to set `force=true`: explicit "replace" actions or admin tools where the user confirmed overwrite. Not for import/index flows — Originals must not be clobbered.
## Archive Extraction — Security Checklist
- Validate ZIP entry names with a safe join; reject absolute paths (e.g. `/etc/passwd`), Windows drive/volume paths (`C:\\…` or `C:/…`), and any entry that escapes the target directory after cleaning (`..` traversal).
- ZIP entry names use slash semantics, not host OS semantics: validate in ZIP-name space with `path.Clean` / `path.IsAbs`, reject backslashes (`\`), and use `path.Base` for hidden-name checks. Convert to OS paths only at write time via `filepath.FromSlash(...)`. Enforce destination containment with `filepath.Rel(...)` — not string-prefix checks.
- Enforce per-file and total size budgets to prevent resource exhaustion. Skip OS metadata directories (e.g. `__MACOSX`) and reject suspicious names.
- Where this lives: `pkg/fs/zip.go` (`Unzip`, `UnzipFile`, `safeJoin`).
## HTTP Download — Security Checklist
- Use the shared safe HTTP helper: `pkg/http/safe` → `safe.Download(destPath, url, *safe.Options)`. Default policy: only `http/https`, enforced timeouts and max size, writes to a `0600` temp file then renames.
- SSRF protection (mandatory unless explicitly needed for tests): set `AllowPrivate=false` to block private/loopback/multicast/link-local ranges. All redirect targets are validated and the final connected peer IP is also checked. Prefer an image-focused `Accept` header for image downloads.
- Avatars and small images: use `internal/thumb/avatar.SafeDownload` (15 s timeout, 10 MiB, `AllowPrivate=false`).
- Tests using `httptest.Server` on 127.0.0.1 must pass `AllowPrivate=true` explicitly.
- Terminology Glossary: `GLOSSARY.md` (single source for term definitions across specs/docs)
- Package-level `README.md` files under `internal/`, `pkg/`, `frontend/`, and `frontend/src/` for detailed package documentation.
- Frontend dependency pin rationale, override layer, and orphan-audit pattern: `frontend/README.md` (check before bumping any non-caret pin or adding/removing a top-level dep).
> Quick Tip: to inspect GitHub issue details without leaving the terminal, run `curl -s https://api.github.com/repos/photoprism/photoprism/issues/<id>`; if `gh` is set up, you MAY also run `gh issue view <id> -R photoprism/photoprism`.
> Frontend test sequences (Vitest / TestCafe / Playwright), hidden-route UI gotchas, and acceptance-test flow live in `frontend-rules.md`.
about: Report a new and clearly identified bug that must be fixed directly in the application
title: 'SHORT DESCRIPTION OF THE PROBLEM YOU ARE REPORTING'
labels: bug
title: 'Bug: Edit the title before submitting'
type: Bug
assignees: ''
---
PLEASE PROCEED ONLY IF YOU ARE ABSOLUTELY SURE THAT THIS IS NOT A TECHNICAL SUPPORT INCIDENT AND/OR POSSIBLY A PROBLEM WITH SOME OTHER SOFTWARE YOU ARE USING. VISIT <https://www.photoprism.app/kb/getting-support> TO LEARN MORE ABOUT OUR SUPPORT OPTIONS. THANK YOU FOR YOUR CAREFUL CONSIDERATION!
<details>
PLEASE PROCEED ONLY IF YOU ARE SURE THAT THIS IS NOT A TECHNICAL SUPPORT INCIDENT AND/OR POSSIBLY A PROBLEM WITH SOME OTHER SOFTWARE YOU ARE USING:
1. Thoroughly review our [Getting Started](https://docs.photoprism.app/getting-started/) and [User Guides](https://docs.photoprism.app/user-guide/).
2. Work through the [Troubleshooting Checklists](https://docs.photoprism.app/getting-started/troubleshooting/) we provide.
3. Do not report [known issues](https://docs.photoprism.app/known-issues/) or [missing features](https://github.com/photoprism/photoprism/issues) as bugs.
4. Use [GitHub Discussions](https://github.com/photoprism/photoprism/discussions) for community help, and visit our [Support Guide](https://www.photoprism.app/kb/getting-support/) to learn more about support options.
#### 1. What is not working as documented?
THANK YOU! 💎
</details>
Be as specific as possible and explain which part of the software is not [working as documented](https://docs.photoprism.app/), e.g. "image not found" or "wrong thumbnail" would not be detailed enough.
**Be as specific as possible and explain which part of the software is not [working as documented](https://docs.photoprism.app/), e.g. "image not found" or "wrong thumbnail" would not be detailed enough.**
Links to the related documentation on [docs.photoprism.app](https://docs.photoprism.app/):
- ...
*Please never report [known issues](https://docs.photoprism.app/known-issues/) or [missing features](https://github.com/photoprism/photoprism/issues) as bugs, and do not submit bug reports for the purpose of getting [technical support](https://www.photoprism.app/kb/getting-support) or because you have not received a response in our [public community forums](https://github.com/photoprism/photoprism/discussions). Thank you very much!*
*Please never report [known issues](https://docs.photoprism.app/known-issues/) or [missing features](https://github.com/photoprism/photoprism/issues) as bugs, and do not submit bug reports for the purpose of getting [technical support](https://www.photoprism.app/kb/getting-support/) or because you have not received a response in our [public community forums](https://github.com/photoprism/photoprism/discussions). Thank you very much!*
#### 2. How can we reproduce it?
### 1. How can we reproduce it?
Steps to reproduce the behavior:
@ -31,23 +36,23 @@ Steps to reproduce the behavior:
When reporting an import, indexing, or performance issue, please include the number and type of pictures in your library, as well as any configuration options you have changed, such as for thumbnail quality.
#### 3. What behavior do you expect?
### 2. What behavior do you expect?
Give us a clear and concise description of what you expect.
#### 4. What could be the cause of your problem?
### 3. What could be the cause of your problem?
Always try to determine the cause of your problem using the checklists at <https://docs.photoprism.app/getting-started/troubleshooting/> before submitting a bug report.
#### 5. Can you provide us with example files for testing, error logs, or screenshots?
### 4. Can you provide us with example files for testing, error logs, or screenshots?
Please include sample files or screenshots that help to reproduce your problem. You can also email files or share a download link, see <https://www.photoprism.app/contact> for details.
Please include sample files or screenshots that help to reproduce your problem. You can also email files or share a download link, see <https://www.photoprism.app/contact/> for details.
Visit <https://docs.photoprism.app/getting-started/troubleshooting/browsers/> to learn how to diagnose frontend issues.
**Important: If it is an import, indexing or metadata issue, we require sample files and logs from you.** Otherwise, we will not be able to process your report. If it is an import problem specifically, please always provide us with an archive of the files before you imported them so we can reproduce the behavior.
**Important: Attach or link to files that help us reproduce the problem. Import and indexing issues require sample files and logs.** Otherwise, we will not be able to process your report. If it is an import problem specifically, please always provide us with an archive of the files before you imported them so we can reproduce the behavior.
@ -63,7 +68,7 @@ You can find the version/build number of the app in *Settings* by scrolling to t
*Always provide database and operating system details if it is a backend, import, or indexing issue. Should it be a frontend issue, at a minimum we require you to provide web browser and operating system details. When reporting a performance problem, we ask that you provide us with complete information about your environment, as there may be more than one cause.*
#### 7. On what kind of device is PhotoPrism installed?
### 6. On what kind of device is PhotoPrism installed?
This is especially important if you are reporting a performance, import, or indexing issue. You can skip this if you're reporting a problem you found in our public demo, or if it's a completely unrelated issue, such as incorrect page layout.
@ -77,9 +82,9 @@ This is especially important if you are reporting a performance, import, or inde
*Always provide device, memory, and storage details if you have a backend, performance, import, or indexing issue.*
#### 8. Do you use a Reverse Proxy, Firewall, VPN, or CDN?
### 7. Do you use a Reverse Proxy, Firewall, VPN, or CDN?
If yes, please specify type and version. You can skip this if you are reporting a completely unrelated issue.
Describe your network setup. If applicable, include details about your NGINX or other reverse proxy configuration.
*Always provide this information when you have a reliability, performance, or frontend problem, such as failed uploads, connection errors, broken thumbnails, or video playback issues.*
description:Report a new and clearly identified bug that must be fixed directly in the application.
title:"Bug: Edit the title before submitting"
type:Bug
assignees:[]
body:
- type:markdown
attributes:
value:|
Please proceed only if you are sure that this is not a technical support incident and/or a problem with some other software you are using:
1. Thoroughly review our [Getting Started](https://docs.photoprism.app/getting-started/) and [User Guides](https://docs.photoprism.app/user-guide/).
2. Work through the [Troubleshooting Checklists](https://docs.photoprism.app/getting-started/troubleshooting/) we provide.
3. Do not report [known issues](https://docs.photoprism.app/known-issues/) or [missing features](https://github.com/photoprism/photoprism/issues) as bugs.
4. Use [GitHub Discussions](https://github.com/photoprism/photoprism/discussions) for community help, and visit our [Support Guide](https://www.photoprism.app/kb/getting-support/) to learn more about support options.
- type:textarea
id:documented-behavior
attributes:
label:What is not working as documented?
description:Be specific and include links to the relevant documentation when possible.
placeholder:Describe the incorrect behavior and link to the related documentation on https://docs.photoprism.app/.
validations:
required:true
- type:textarea
id:reproduction-steps
attributes:
label:How can we reproduce it?
description:Provide numbered steps so we can reproduce the behavior reliably.
placeholder:|
1. Go to ...
2. Click ...
3. Scroll to ...
4. Expected vs. actual result
validations:
required:true
- type:checkboxes
id:prerequisites
attributes:
label:Have you verified that no similar reports exist?
description:Confirm that you read the relevant documentation, this is not a support request, and that no similar reports already exist.
options:
- label:This is a new bug that has not yet been reported or documented
required:true
- type:textarea
id:expected-behavior
attributes:
label:What behavior do you expect?
description:Share the correct outcome you expected to see.
validations:
required:true
- type:textarea
id:possible-cause
attributes:
label:What could be the cause?
description:Summarize any investigation you have already completed and the potential root cause.
- type:textarea
id:software-versions
attributes:
label:Which software versions do you use?
description:Include all relevant software versions so we can reproduce the environment.
value:|
- PhotoPrism Edition & Version (Build):
- Database Type & Version(s):
- Operating System(s):
- Browser Type & Version(s):
- Ad Blockers, Antivirus, and Plugins:
validations:
required:true
- type:textarea
id:installation-device
attributes:
label:Onwhat device is PhotoPrism installed?
description:Provide hardware details, especially for performance, import, or indexing issues.
value:|
- CPU/Device Type(s):
- Physical Memory & Swap in GB:
- Storage (HDD/SSD/RAID, USB, Network):
- type:textarea
id:networking-setup
attributes:
label:Do you use a reverse proxy, firewall, VPN, or CDN?
description:Describe your network setup. If applicable, include details about your NGINX or other reverse proxy configuration.
placeholder:Describe proxies, VPNs, CDNs, or firewall software involved. Include versions and configuration snippets when helpful.
- type:upload
id:supporting-material
attributes:
label:Logs, Sample Files, or Screenshots
description:Attach or link to files that help us reproduce the problem. Import and indexing issues require sample files and logs.
title: 'Category: Short Description (PLEASE CHANGE)'
labels: idea
title: 'Feature: Edit the title before submitting'
type: Feature
assignees: ''
---
PLEASE ONLY PROCEED IF YOU ARE ABSOLUTELY SURE THAT THIS IS NOT A TECHNICAL SUPPORT INCIDENT AND/OR A PROBLEM WITH OTHER SOFTWARE YOU ARE USING. PLEASE ALSO CHECK OUR PUBLIC ROADMAP AND TRY TO FIND EXISTING FEATURE REQUESTS FIRST:
<details>
PLEASE PROCEED ONLY IF YOU ARE SURE THAT THIS IS NOT A TECHNICAL SUPPORT INCIDENT AND/OR POSSIBLY A PROBLEM WITH SOME OTHER SOFTWARE YOU ARE USING. CHECK OUR PUBLIC ROADMAP AND TRY TO FIND EXISTING FEATURE REQUESTS FIRST:
**Describe what problem this solves and why this would be valuable to many users**
**As a <role>, I want <goal>, so that <outcome>.**
All issue descriptions MUST begin with a one-sentence user story in the format shown above:
- Issue titles MUST be concise, use the imperative mood, and start with a single capitalized prefix followed by a colon and a space, e.g. `Search: Add filter for RAW image formats`.
- Follow the user story with information about the problem the feature solves, the alternatives you considered, and any other relevant details.
- Use level-3 Markdown headings to separate sections like `### Background`, `### Additional Context`, `### Open Questions`, and `### Acceptance Criteria`.
### Background
**What problem does this solve, and why would it be valuable to many users?**
A clear and concise description of what the problem is and why it is important to solve it.
**Describe the solution you'd like**
**What solution do you propose?**
A clear and concise description of what you suggest to happen.
**Describe alternatives you've considered**
**Which alternatives or workarounds have you considered?**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
### Additional Context
Add any other context or screenshots about the feature request here.
- Add any other context or screenshots about the feature request here.
### Open Questions
- [ ] <question> — options A/B/C and implications.
- [ ] <question> — potential ambiguity or conflict to clarify.
### Acceptance Criteria
- [ ] <component> MUST <expectedbehavior>
- [ ] <component> SHOULD <expectedbehavior>
- [ ] <component> MAY <expectedbehavior>
### References
- Add links to [external documentation](https://www.example.com/), references, or examples.
Because we want to create the best possible product for our users, we have a set of criteria to ensure that all submissions are acceptable, see https://docs.photoprism.app/developer-guide/pull-requests/ for details.
(1) Please provide a concise description of your pull request.
- What does it implement / fix / improve? Why?
- Are the changes related to an existing issue?
(2) After you submit your first pull request, you will be asked to accept our CLA, see https://www.photoprism.app/cla.
(3) Finally, please confirm that the following criteria are met by replacing "[ ]" with "[x]" (also possible at a later time).
-->
Acceptance Criteria:
- [ ] Features and enhancements must be fully implemented so that they can be released at any time without additional work
- [ ] Automated unit and/or acceptance tests are mandatory to ensure the changes work as expected and to reduce repetitive manual work
- [ ] Frontend components must be responsive to work and look properly on phones, tablets, and desktop computers; you must have tested them on all major browsers and different devices
- [ ] Documentation and translation updates should be provided if needed
- [ ] In case you submit database-related changes, they must be tested and compatible with SQLite 3 and MariaDB 10.5.12+
### Description
<!--
We appreciate your interest in contributing! Please provide a brief description of your changes so that we know what is included in this pull request, and confirm that it meets the acceptance criteria:
Since reviewing, testing and finally merging pull requests requires significant resources on our side, this can take several months if it's not just a small fix, especially if extensive testing is required to prevent bugs from getting into our stable version.
We thank you for your patience! :)
What does it aim to implement, fix or improve? Why?
-->
These changes implement/fix/improve...
#### Related Issues
- Links to issues that this PR fixes, implements, or is otherwise related to...
### Acceptance Criteria
<!-- You may add additional criteria and/or remove criteria that do not apply, e.g. because your PR does not include frontend changes: -->
- [ ] New features or enhancements are fully implemented and do not break existing functionality, so that they can be released at any time without requiring additional work
- [ ] Automated unit and/or acceptance tests are included to ensure that changes work as expected and to reduce repetitive manual work
- [ ] Documentation has been / will be updated, especially as it relates to new configuration options or potentially disruptive changes
- [ ] The user interface has been tested on Chrome, Safari, and Firefox and is fully responsive for use on phones, tablets, and desktop computers
- [ ] Database-related changes have been successfully tested with SQLite 3 and MariaDB 10.5.12+
<!--
Contribution Agreement:
After submitting your first pull request, you will be asked to confirm our contribution agreement. This allows us to safely use your Contribution in all our projects without risking unexpected legal disputes or having to repeatedly ask for permission.
The agreement is solely for our protection and that of our users. It does not grant us exclusive rights to your code.
PhotoPrism UG ("PhotoPrism", "we" or "us") hereby confirms to you that, to the fullest extent permitted by applicable law, this Contribution is provided "AS IS" WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY IMPLIED WARRANTIES OR CONDITIONS OF NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. You have no obligation to provide support, maintenance, or other services for your Contribution.
- Go: `go fmt`, `goimports` (see `make fmt`, `make fmt-go`)
- JS/Vue: use scripts in `frontend/package.json` (ESLint + Prettier)
## Project Structure & Languages
- Backend: Go (`internal/`, `pkg/`, `cmd/`) + MariaDB/SQLite
- Frontend: Vue 3 + Vuetify 3 (`frontend/`)
- Docker/compose for dev/CI; Traefik used for local TLS in dev profile when enabled.
## Code Review Instructions (for Copilot)
- Respect SOT above; do not invent flags, env vars, or Compose options. If a command/env var is not in the docs/Makefile/CLI help, say “not documented” and suggest checking the SOT.
- Prefer minimal, surgical diffs. Propose changes as concrete patches and reference the relevant Makefile target or doc section.
- Before suggesting refactors, check tests and build tasks exist and can pass with the change. If tests are missing, suggest specific Vitest/Go test snippets.
- Security: never suggest committing secrets; prefer env vars and `.env` in dev only. Point to SECURITY.md for disclosures.
- Data safety: never run or recommend destructive CLI operations in examples without explicit backups and `--yes`. Avoid `photoprism reset`, `photoprism users reset`, `photoprism auth reset`, or `photoprism audit reset` in PR comments unless the change is specifically about those commands; if unavoidable, add bold warnings and backup steps.
- Database/schema: if a change touches persistence, check for migrations and mention `photoprism migrate` / `migrations` commands.
- API changes: align with the REST API docs/spec; include curl examples only if they match current endpoints and auth notes.
- UX/i18n: keep UI strings concise, translatable, and consistent; avoid hard-coded language constructs; prefer existing patterns/components.
## Style & Patterns
- Go: idiomatic Go, clear error handling, small functions, packages with focused responsibilities. Keep public surface minimal. Add/adjust unit tests.
- Vue/JS: options API, store patterns as in existing code, avoid breaking translations. Keep ESLint/Prettier clean.
- Config & flags: suggest `photoprism --help`, `photoprism show config-options` or `photoprism show config-yaml` to verify names before using them.
## Performance & Reliability
- Prefer using existing caches, workers, and batching strategies referenced in code and Makefile. Consider memory/CPU impact; suggest benchmarks or profiling only when justified.
## When Unsure
- Ask for the exact Makefile target or doc link you need, then proceed. Defer to SOT if any conflict arises.
## References To Help Copilot Answer Questions
- Show supported formats/filters: run `photoprism show file-formats` and `photoprism show search-filters` (use results rather than guessing).
- For CI/dev containers, assume Linux/Unix shell by default; link Windows specifics in Developer Guide FAQ.
## Output Expectations
- Prefer short, actionable comments with code blocks that pass tests locally: `make test-js` (frontend) / `make test-go` (backend)
- If a suggestion requires additional context (e.g., DB access, external service), call it out explicitly.
## Safety Checklist Before Proposing a CLI Command
- Include a dry-run or non-destructive variant if possible.
- Recommend creating/using backups before any reset/migrate.
- Package docs: `README.md` files under `internal/`, `pkg/`, `frontend/`, and `frontend/src/`
- Frontend dependency pins, override layer, and orphan-audit pattern: [`frontend/README.md`](frontend/README.md) (read before bumping any non-caret pin or adding/removing a top-level dep)
- [`pkg/AGENTS.md`](pkg/AGENTS.md): `pkg/*` security and test rules.
Optional nested repositories such as `plus/`, `pro/`, `portal/`, and `specs/` may contain their own `AGENTS.md` files. When present, treat those files as additional directory-local guidance.
## Local Agent Progress
- Use `.agents/TODO.md` for actionable tasks and `.agents/DONE.md` for completed work.
- These files are local workflow aids and may not exist in every workspace.
## Style Notes
### Commit Messages
- Use concise imperative subjects with a one-word prefix, for example `Config: Add tests for "darktable-cli" path detection`.
- Append issue or PR IDs when relevant.
- Commit messages must not exceed 80 characters.
### GitHub Issues
- Titles MUST be concise, imperative, and start with one capitalized prefix plus `: `, for example `Search: Add filter for RAW image formats`.
- Descriptions MUST begin with a one-sentence bold user story: `**As a <role>, I want <goal>, so that <outcome>.**`
- Use level-3 Markdown headings for sections within issue descriptions, for example `### Acceptance Criteria`.
- Follow with behavior, rationale, technical considerations, and constraints.
- End with `- [ ]` checklist items for the acceptance criteria, each using `MUST`, `SHOULD`, or `MAY`.
- Keep the checklist current: once the work for a criterion is implemented **and verified**, mark it done (`- [x]`).
- Leave items that are unverified, not yet implemented, or skipped optional (`MAY`) enhancements unchecked.
- An issue is complete only when every `MUST` is checked; never tick a box on the strength of a plan alone or an unrun test.
- When referencing an issue from a commit that fulfills some of its criteria, update the matching boxes first.
- Agents MUST create, edit, close, reopen, relabel, or otherwise modify GitHub issues only when explicitly requested by the user.
### Specifications & Documentation
- Markdown headings use a Chicago-style title case, with additional code- and path-aware normalization rules (see *Title Case rules* below). Always spell the product name as `PhotoPrism`.
- Put option flags before positional arguments unless the command requires another order.
- Use RFC 3339 UTC timestamps and valid ID, UID, and UUID examples in docs and tests.
- The nested `specs/` repository may be absent. Do not add main-repo `Makefile` targets that depend on it; when present, you may run its tools manually.
- Testing guides live at `specs/dev/backend-testing.md` and `specs/dev/frontend-testing.md`.
- Do not read, analyze, or modify `specs/generated/`; refer humans to `specs/generated/README.md` when regeneration is needed.
- Refresh `**Last Updated:**` when you change document contents, but leave it unchanged for whitespace-only or formatting-only edits.
- Nested Git repositories may appear ignored; change into them before staging or committing updates.
Title Case rules (Chicago-style, with code- and path-aware normalization):
- Capitalize the first word, the first word after a colon, dash, or end punctuation, and all major words, including the second part of a hyphenated major word.
- Lowercase only articles, short conjunctions, and short prepositions of three letters or fewer when they are not in one of those positions.
- Preserve known acronyms (for example, API, CLI, HTTP, JSON) and slash-separated acronym groups (for example, CSV/TSV) as uppercase.
- Preserve RFC 2119 / RFC 8174 normative keywords (MUST, SHOULD, MAY, SHALL, REQUIRED, RECOMMENDED, OPTIONAL) as uppercase when used in their normative sense.
- Preserve inline code spans (`` `foo` ``), file paths (e.g. `docs/foo-bar.md`), and slash commands (e.g. `/grill-me`) verbatim; do not recase their contents.
- Use `&` instead of `And`/`Or` in headings.
## Safety & Data
- If `git status` shows unexpected changes, assume a human may be editing; ask before using reset-style commands.
- Do not run `git config` at either the global or repository level.
- Do not run destructive commands against production data; prefer ephemeral volumes and test fixtures for acceptance tests.
- Never commit secrets, local configurations, or cache files; use environment variables or a local `.env`.
- Ensure `.env`, `.config`, `.local`, `.codex`, and `.gocache` are ignored in `.gitignore` and `.dockerignore`.
- Prefer existing caches, workers, and batching strategies already referenced by the code and `Makefile`.
- Consider CPU and memory impact; only suggest profiling or benchmarks when justified.
- If anything here conflicts with the `Makefile` or the sources of truth, ask for clarification before proceeding.
## Project Layout & Shared Rules
- Backend: Go in `internal/`, `pkg/`, and `cmd/`, backed by MariaDB or SQLite.
- Frontend: Vue 3 plus Vuetify 3 under `frontend/`.
- Local dev and CI use Docker Compose; Traefik provides local TLS via `*.localssl.dev`.
- Code in `pkg/*` must not import from `internal/*`. If you need config, entity, or DB access, add code under `internal/`.
- Shared Go rules:
- After Go edits, run `make fmt-go` and keep `gofmt` tab indentation.
- Every added/modified Go function, including unexported helpers, must have focused test coverage in the corresponding `*_test.go` files; update existing tests or add new ones as needed.
- Every Go package must contain a root `<package>.go` file with the standard license header and a short package description comment.
- Use `pkg/fs` permission constants: `fs.ModeDir`, `fs.ModeFile`, `fs.ModeConfigFile`, `fs.ModeSecretFile`, and `fs.ModeBackupFile`.
- When importing the stdlib `io/fs`, alias it to avoid collisions, for example `iofs "io/fs"` or `gofs "io/fs"`.
- Do not pass stdlib `io/fs` mode flags where permission bits are expected.
- Prefer `filepath.Join` for filesystem paths and `path.Join` only for URL paths.
- Normalize slash-based logical paths stored in DB, config, or API payloads with `clean.SlashPath(...)`.
- Shared JS/Vue rules:
- Added/modified JavaScript functions, including helpers, should be tested whenever practical; update existing tests or add new ones as needed.
- Added/modified Vue components should have component-test coverage, and existing component tests should be updated as needed when behavior changes.
- When adding a metadata source such as `SrcOllama` or `SrcOpenAI`, update both `internal/entity/src.go` and `frontend/src/common/util.js` so backend and UI stay aligned.
### JS/Go Code Comments
A doc comment is **required** for every function (including unexported helpers), as well as for every non-trivial Vue `methods:` / `computed:` / watcher:
- Keep comments **compact** and default to one line for "what" in the format `// Name does X.`. Skip trivial getters (`isOpen: () => this.open`).
- Add 1-2 follow-up lines (`// …`) **only** if the "why" is non-obvious: a hidden invariant, a workaround that would otherwise be undone by a future cleanup, a contract a reader can't infer from the code. If readers can infer the "why" from the function body or a nearby line, then omit it.
- Multi-paragraph explanations belong in `specs/`, package `README.md` files, or GitHub issues — never in the source itself.
Doc comments for packages and exported identifiers must be complete sentences that begin with the name of the thing being described and end with a period. For short examples in comments, indent code instead of using backticks.
Use US English spelling in all code comments (`parameterized`, `behavior`, `color`, `serialize`, `normalize`, `optimize`, …) — not the British `-ised`/`-our`/`-re` variants.
> **Don't include in code comments:** Issue / PR numbers, "previously…" history, alternatives considered, what the function used to do, references to old commits, names of subsequent reviewers, or any narrative that names the change rather than the steady-state behavior. That context belongs in commit messages, specs, or handover notes.
## Agent Runtime
- Detect container mode by checking for `/.dockerenv`.
- If the repo path is `/go/src/github.com/photoprism/photoprism` and `/.dockerenv` is absent, treat the environment as host mode with a bind mount and prefer host-side Docker commands.
- Inside the container, prefer `npm exec --yes <agent> -- --help` or `npx <agent> ...`; if a global npm install is unavoidable, install it only inside the container.
- The `photoprism/develop` base image and the repo `Makefile` both set `NPM_CONFIG_IGNORE_SCRIPTS=true`, so `npm ci`/`npm install` via `make` targets skip install scripts out of the box. When running npm directly in an environment that does not set or inherit that default, pass `--ignore-scripts` explicitly to mitigate supply-chain attacks. Rebuild native addons with `npm rebuild --ignore-scripts=false <pkg>` — a bare `npm rebuild` is a silent no-op wherever the env default is active.
- On the host, use the vendor-recommended install method and run from the repository root so agent discovery sees this file.
- `docker compose --profile=all down --remove-orphans` or `make down`
- Container mode:
- `make dep`
- `make build-js` and `make build-go`
- `make watch-js` or `cd frontend && npm run watch`
- `./photoprism start`
- Local URLs: `http://localhost:2342/` and, with Traefik, `https://app.localssl.dev/`
- Local compose defaults to `admin` / `photoprism`; inspect `compose.yaml` if they differ.
- Do not use the Docker CLI inside the container; manage Compose from the host instead.
- The public CLI name is always `photoprism`; development-only side-by-side binaries may use edition-specific names.
- Our command examples assume a Linux or Unix shell on 64-bit AMD64 or ARM64; see the Developer Guide FAQ for Windows-specific notes.
Formatting and test entry points:
- Full suite: `make test`, `make lint`
- Go-specific lint, format, and package-test rules live in [`internal/AGENTS.md`](internal/AGENTS.md).
- Frontend lint, Vitest, acceptance, and Playwright rules live in [`frontend/AGENTS.md`](frontend/AGENTS.md).
- Go tests live next to their sources; use PascalCase `t.Run(...)` names for related subtests. Keep consecutive subtests inside the same `Test*` function back-to-back without blank lines so the cases read as a compact table; reserve blank lines for separating distinct setup blocks.
- Do not run multiple test commands in parallel; suites share fixtures, assets, and database state.
- Prefer focused test runs such as `go test ./path/to/pkg -run Name -count=1` while iterating.
- Use `mariadb -D photoprism` inside the dev shell when you need to inspect MariaDB state directly.
- Run `shellcheck <file>` on edited shell scripts, or use the corresponding `make` target.
### Container Image Builds
- **Never mix Debian and Ubuntu `apt` repositories in the same image:**
- Don't add a Debian source to an Ubuntu base (or vice versa) to install a single missing package — the transitive deps drift, apt's solver pulls newer libraries from the foreign distro, and other build steps in the same `RUN` (e.g. `install-libheif.sh` running `apt-get install libavcodec-dev`) silently link against the wrong soname.
- Symptoms surface much later as `dlopen: libfoo.so.N: cannot open shared object file` at image runtime, with the binary referencing a soname that exists only in the foreign distro.
- If a package isn't available in the host distro's repos, prefer (a) a same-distro PPA / backports source, (b) a vendor-supplied .deb (e.g. Google Chrome from `dl.google.com`), or (c) a from-source build pinned to a known version.
- Give agents and contributors a fast, reliable map of where things live and how they fit together, so you can add features, fix bugs, and write tests without spelunking.
- Sources of truth: prefer Makefile targets and the Developer Guide linked in AGENTS.md.
Quick Start
- Inside dev container (recommended):
- Install deps: `make dep`
- Build backend: `make build-go`
- Lint Go (golangci-lint): `make lint-go` (uses `.golangci.yml`; prints findings without failing) or run both stacks with `make lint`
- Run server: `./photoprism start`
- Open: http://localhost:2342/ or https://app.localssl.dev/ (Traefik required)
- Routes: `internal/server/routes.go` (registers all v1 API groups + UI, WebDAV, sharing, .well-known)
- API group: `APIv1 = router.Group(conf.BaseUri("/api/v1"), Api(conf))`
High-Level Package Map (Go)
- `internal/api` — Gin handlers and Swagger annotations; only glue, no business logic
- `internal/commands` — CLI command definitions and orchestration (`start`, `index`, `import`, `migrate`, etc.); `commands.go` wires them into the app and subpackages like `catalog` emit CLI documentation.
- Label lookup helpers now live in `internal/entity/label*.go`; reuse `FindLabels(...)`, `FindLabelIDs(...)`, and `LabelSlugs(...)` for homophone-aware exact-name/slug resolution instead of duplicating slug SQL in callers.
- Recursive directory discovery prefers `PROPFIND Depth: infinity` and falls back to iterative `Depth: 1` traversal for incompatible servers.
- Hidden dotfiles and entries inside hidden dot-directories are excluded from listings and fallback traversal because they often represent lock files, partial uploads, or provider metadata.
- Service timeouts apply to control operations (`Files`, `Directories`, `Mkdir`, `Delete`), while `Upload` and `Download` avoid total request deadlines and instead use connection-level safeguards.
- `internal/event` — logging, pub/sub, audit; canonical outcome tokens live in `pkg/log/status` (use helpers like `status.Error(err)` when the sanitized message should be the outcome). Docs: `internal/event/README.md`.
- `pkg/*` — reusable utilities (must never import from `internal/*`), e.g. `pkg/clean`, `pkg/enum`, `pkg/fs`, `pkg/txt`, `pkg/http/header`
Templates & Static Assets
- Entry HTML lives in `assets/templates/index.gohtml`, which includes the splash markup from `app.gohtml` and the SPA loader from `app.js.gohtml`.
- OIDC login completion for the SPA is bridged through `assets/templates/auth.gohtml`, which clears legacy/namespaced session keys and writes the session into the preferred namespaced browser store selected by the login UI toggle in `frontend/src/page/auth/login.vue`.
- The browser check logic resides in `assets/static/js/browser-check.js` and is included via `app.js.gohtml`; it performs capability checks (Promise, fetch, AbortController, `script.noModule`, etc.) before the main bundle runs.
- Update this file (and the partial) in lockstep with `pro/assets/templates/index.gohtml`, `plus/assets/templates/index.gohtml`, and `portal/assets/templates/index.gohtml`, because those editions import the same partial.
- Keep the script tag order unchanged so the browser check executes before the main bundle.
- `splash.gohtml` renders the loading screen text while the bundle loads; styles are in `frontend/src/css/splash.css`.
- When adjusting browser support messaging, update both the loader partial and splash styles so the warning message stays consistent across editions.
- Service worker routes live in `internal/server/routes_webapp.go`. Handlers for `sw.js`, `sw-scope-cleanup.js`, and Workbox runtime files (`/workbox-:hash`) are defined there so service workers run under both the site root and a base URI; remember Gin’s `:hash` parameter excludes the `.js` suffix, so the handler/test matches the full filename manually.
HTTP API
- Handlers live in `internal/api/*.go` and are registered in `internal/server/routes.go`.
- Annotate new endpoints in handler files; generate docs with: `make fmt-go swag-fmt && make swag`.
- Do not edit `internal/api/swagger.json` by hand.
- Swagger notes:
- Use full `/api/v1/...` in every `@Router` annotation (match the group prefix).
- Annotate only public handlers; skip internal helpers to avoid stray generic paths.
- `make swag-json` runs a stabilization step (`swaggerfix`) removing duplicated enums for `time.Duration`; API uses integer nanoseconds for durations.
- `/api/v1/metrics` (see `internal/api/metrics.go`) exposes Prometheus metrics, including cached filesystem/account usage derived from `config.Usage()`, registered user/guest totals, and portal cluster node counts when `NodeRole=portal`; the handler returns the standard Prometheus exposition content type (`text/plain; version=0.0.4`).
- Common groups in `routes.go`: sessions, OAuth/OIDC, config, users, services, thumbnails, video, downloads/zip, index/import, photos/files/labels/subjects/faces, batch ops, cluster, technical (metrics, status, echo).
- Hidden search behavior (used by the hidden route under the configured frontend URI, default `/library/hidden` for CE/Plus/Pro and `/portal/hidden` for Portal) is implemented in `internal/entity/search/photos.go`:
- `frm.Hidden` enforces `photos.photo_quality = -1` and `photos.deleted_at IS NULL`.
- Non-hidden searches exclude errored files by default (`files.file_error = ''`) unless `frm.Error` is explicitly set.
- Search DTOs in `internal/entity/search/photos_results.go` expose `FileError` (`files.file_error`) so clients can render hidden reasons without loading full file details first.
Configuration & Flags
- Options struct: `internal/config/options.go` with `yaml:"…"` (for `defaults.yml`/`options.yml`), `json:"…"` (clients/API), and `flag:"…"` (CLI flags/env) tags.
- For secrets/internals: `json:"-"` disables JSON processing to prevent values from being exposed through the API (see `internal/api/config_options.go`).
- If needed: `yaml:"-"` disables YAML processing; `flag:"-"` prevents `ApplyCliContext()` from assigning CLI values (flags/env variables) to a field, without affecting the flags in `internal/config/flags.go`.
- Annotations may include edition tags like `tags:"plus,pro"` to control visibility (see `internal/config/options_report.go` logic).
- Global flags/env: `internal/config/flags.go` (`EnvVars(...)`)
- Available flags/env: `internal/config/cli_flags_report.go` + `internal/config/report_sections.go` → surfaced by `photoprism show config-options --md/--json`
- YAML options mapping: `internal/config/options_report.go` + `internal/config/report_sections.go` → surfaced by `photoprism show config-yaml --md/--json`
- Report current values: `internal/config/report.go` → surfaced by `photoprism show config` (alias `photoprism config --md`).
- CLI commands catalog: `internal/commands/show_commands.go` → surfaced by `photoprism show commands` (Markdown by default; `--json` alternative; `--nested` optional tree; `--all` includes hidden commands/flags; nested `help` subcommands omitted).
- `Config.SaveOptionsPatch(...)` in `internal/config/config.go` for generic `options.yml` merge/write/reload.
- `Config.SaveClusterOptionsUpdate(...)` in `internal/config/config_cluster.go` for cluster metadata updates (`ClusterUUID`, `NodeUUID`, `NodeClientID`, DB fields, etc.).
- Getters are grouped by topic, e.g. DB in `internal/config/config_db.go`, server in `config_server.go`, TLS in `config_tls.go`, etc.
- Client Config (read-only)
- Endpoint: GET `/api/v1/config` (see `internal/api/api_client_config.go`).
- CDN behavior: Requests carrying CDN headers are rejected with `404` to prevent intermediary cache mix-ups between public and session-specific config payloads.
- Assembly: Built from `internal/config/client_config.go` (not a direct serialization of Options) plus extension values registered via `config.Register` in `internal/config/extensions.go`.
- Updates: Back-end calls `UpdateClientConfig()` to publish "config.updated" over websockets after changes (see `internal/api/config_options.go` and `internal/api/config_settings.go`).
- ACL/mode aware: Values are filtered by user/session and may differ for public vs. authenticated users.
- Don’t expose secrets: Treat it as client-visible; avoid sensitive data. To add fields, extend client values via `config.Register` rather than exposing Options directly.
- Refresh cadence: The web UI (non‑mobile) also polls for updates every 10 minutes via `$config.update()` in `frontend/src/app.js`, complementing the websocket push.
- Parsing/helpers: `internal/auth/oidc/groups.go` normalizes IDs, detects Entra `_claim_names` overage, maps groups→roles, and enforces required membership in `internal/api/oidc_redirect.go`.
- Overage: if `_claim_names.groups` is present and no groups are returned, login fails when required groups are configured; Graph fetch is not implemented yet.
Database & Migrations
- Driver: GORM v1 (`github.com/jinzhu/gorm`). No `WithContext`. Use `db.Raw(stmt).Scan(&nop)` for raw SQL.
- Entities and helpers: `internal/entity/*.go` and subpackages (`query`, `search`, `sortby`).
- Migrations engine: `internal/entity/migrate/*` — run via `config.MigrateDb()`; CLI: `photoprism migrate` / `photoprism migrations`.
- DB init/migrate flow: `internal/config/config_db.go` chooses driver/DSN, sets `gorm:table_options`, then `entity.InitDb(migrate.Opt(...))`.
AuthN/Z & Sessions
- Session model and cache: `internal/entity/auth_session*` and `internal/auth/session/*` (cleanup worker).
- `internal/entity/auth_session_jwt.go` builds transient sessions from portal-issued JWTs; used by `internal/api/api_auth_jwt.go` when nodes authenticate portal requests.
- ACL: `internal/auth/acl/*` — roles, grants, scopes; use constants; avoid logging secrets, compare tokens constant‑time; for scope checks use `acl.ScopePermits` / `ScopeAttrPermits` instead of rolling your own parsing.
- OIDC: `internal/auth/oidc/*`.
Media Processing
- Thumbnails: `internal/thumb/*` and helpers in `internal/photoprism/mediafile_thumbs.go`.
- Metadata: `internal/meta/*`.
- FFmpeg integration: `internal/ffmpeg/*`.
- HEIF tooling: distribution binaries live under `scripts/dist/install-libheif.sh`; regenerate archives with `make build-libheif-*` (wraps `scripts/dist/build-libheif.sh` for each supported distro/arch) before publishing to `dl.photoprism.app/dist/libheif/`.
- Folder album consistency:
- `internal/entity/folder.go` keeps `FindFolder(...)` unscoped for create/index conflict handling, so a soft-deleted row cannot cause repeated insert/fail/not-found loops.
- `internal/photoprism/index.go` runs `entity.ReconcileOriginalsFolderAlbums(...)` only on forced rescans, after the file walk, so regular indexing stays lightweight while complete rescans repair stale/missing folder albums.
Background Workers
- Scheduler and workers: `internal/workers/*.go` (index, vision, meta, sync, backup, share); started from `internal/commands/start.go`.
- Node bootstrap & registration: `internal/service/cluster/node/*` (HTTP to Portal; do not import Portal internals).
- Registration now retries once on 401/403 by rotating the node client secret with the join token and persists the new credentials (falling back to in-memory storage if the secrets directory is read-only).
- Theme sync logs explicitly when refresh/rotation occurs so operators can trace credential churn in standard log levels.
2) Config init, DB init/migrate, session cleanup worker
3) `internal/server/start.go` builds Gin engine, middleware, API group, templates
4) `internal/server/routes.go` registers UI, WebDAV, sharing, well‑known, and all `/api/v1/*` routes
5) Workers and auto‑index start; health endpoints `/livez`, `/readyz` available
Common How‑Tos
- Add a CLI command
- Create `internal/commands/<name>.go` with a `*cli.Command`
- Add it to `PhotoPrism` in `internal/commands/commands.go`
- Tests: prefer `RunWithTestContext` from `internal/commands/commands_test.go` to avoid `os.Exit`
- Add a REST endpoint
- Create handler in `internal/api/<area>.go` with Swagger annotations
- Register it in `internal/server/routes.go`
- Use helpers: `api.ClientIP(c)`, `header.BearerToken(c)`, `Abort*` functions
- Validate pagination bounds (default `count=100`, max `1000`, `offset>=0`) for list endpoints
- Run `make fmt-go swag-fmt && make swag`; keep docs accurate
- Tests: `go test ./internal/api -run <Name>` and focused helpers (`NewApiTest()`, `PerformRequest*`)
- Add a config option
- Add field with tags to `internal/config/options.go`
- Register CLI flag/env in `internal/config/flags.go` via `EnvVars(...)`
- Expose a getter (e.g., in `config_server.go` or topic file)
- Append to `rows` in `*config.Report()` after the same option as in `options.go`
- If value must persist, write back to `options.yml` and reload into memory (prefer `Config.SaveOptionsPatch(...)` and related config-owned helpers over ad-hoc YAML logic).
- When you need the path to defaults/options/settings files, call `pkg/fs.ConfigFilePath` so `.yml` and `.yaml` stay interchangeable.
- Tests: cover CLI/env/file precedence (see `internal/config/test.go` helpers)
- Touch the DB schema
- Use GORM auto-migration, or add a custom migration in `internal/entity/migrate/<dialect>/...` and run `go generate` or `make generate` (runs `go generate` for all packages)
- Bump/review version gates in `migrate.Version` usage via `config_db.go`
- Tests: run against SQLite by default; for MySQL cases, gate appropriately
- Tests: `go test ./pkg/http/safe -count=1` (includes redirect SSRF cases); avatars: `go test ./internal/thumb/avatar -count=1`.
- CDN guards for credential flows:
- Auth/session and OAuth/OIDC endpoints reject CDN-marked requests.
- Cluster bootstrap endpoint `POST /api/v1/cluster/nodes/register` also rejects CDN-marked requests to avoid caching responses that may contain bootstrap secrets.
Performance & Limits
- Prefer existing caches/workers/batching as per Makefile and code.
- When adding list endpoints, default `count=100` (max `1000`); set `Cache-Control: no-store` for secrets.
Conventions & Rules of Thumb
- Respect package boundaries: code in `pkg/*` must not import `internal/*`.
- Prefer constants/helpers from `pkg/http/header` over string literals.
- Never log secrets; compare tokens constant‑time.
- Don’t import Portal internals from cluster instance/service bootstraps; use HTTP.
- Prefer small, hermetic unit tests; isolate filesystem paths with `t.TempDir()` and env like `PHOTOPRISM_STORAGE_PATH`.
- Cluster nodes: identify by UUID v7 (internally stored as `NodeUUID`; exposed as `UUID` in API/CLI). The OAuth client ID (`NodeClientID`, exposed as `ClientID`) is for OAuth only. Registry lookups and CLI commands accept UUID, ClientID, or DNS-label name (priority in that order).
Filesystem Permissions & io/fs Aliasing
- Use `github.com/photoprism/photoprism/pkg/fs` permission variables when creating files/dirs:
- `fs.ModeDir` (0o755 with umask), `fs.ModeFile` (0o644 with umask), `fs.ModeConfigFile` (0o664), `fs.ModeSecretFile` (0o600), `fs.ModeBackupFile` (0o600).
- Do not use stdlib `io/fs` mode bits as permission arguments. When importing stdlib `io/fs`, alias it (`iofs`/`gofs`) to avoid `fs.*` collisions with our package.
- Prefer `filepath.Join` for filesystem paths across platforms; use `path.Join` for URLs only.
Cluster Registry & Provisioner Cheatsheet
- UUID‑first everywhere: API paths `{uuid}`, Registry `Get/Delete/RotateSecret` by UUID; explicit `FindByClientID` exists for OAuth.
HMAC is base32 of ClusterUUID+NodeUUID; drivers currently `mysql|mariadb`.
- DSN builder: `BuildDSN(driver, host, port, user, pass, name)`; warns and falls back to MySQL format for unsupported drivers.
- Go tests live beside sources: for `path/to/pkg/<file>.go`, add tests in `path/to/pkg/<file>_test.go` (create if missing). For the same function, group related cases as `t.Run(...)` sub-tests (table-driven where helpful) and name each subtest string in PascalCase.
- Public API and internal registry DTOs use normalized field names:
- `Database` (not `db`) with `Name`, `User`, `Driver`, `RotatedAt`.
- Node-level rotation timestamps use `RotatedAt`.
- Registration returns `Secrets.ClientSecret`; the CLI persists it under config `NodeClientSecret`.
- Admin responses may include `AdvertiseUrl` and `Database`; non-admin responses are redacted by default.
- Cluster CLI highlights:
- `photoprism cluster register` supports `--site-url` and `--advertise-url`. Both values are always forwarded to the Portal regardless of whether they differ.
- Automatic MariaDB credential rotation logic lives in `config.ShouldAutoRotateDatabase()` and is shared by both the CLI and node bootstrap.
- Theme support: `internal/service/cluster/theme/version.go` exposes `DetectVersion`, used by bootstrap, CLI, and API handlers to compare portal vs node theme revisions (prefers `fs.VersionTxtFile`, falls back to `app.js` mtime).
- Registration sanitizes `AppName`, `AppVersion`, and `Theme` with `clean.TypeUnicode`; defaults for app metadata come from `config.About()` / `config.Version()`. `cluster.RegisterResponse` now includes a `Theme` hint when the portal has a newer bundle so nodes can decide whether to download immediately.
- `make swag` — generate Swagger JSON in `internal/api/swagger.json`
- `make fmt-go swag-fmt` — format Go code and Swagger annotations
See Also
- AGENTS.md (repository rules and tips for agents)
- Developer Guide (Setup/Tests/API) — links in AGENTS.md → Sources of Truth
Go Internal Import Rule
- Keep temporary Go helpers inside `internal/...`; the Go toolchain blocks importing `internal/` packages from directories such as `/tmp`, so use a disposable path like `internal/tmp/` when you need scratch space.
**By using the software and services we provide, you agree to our [Terms of Service](https://www.photoprism.app/terms), including our [Privacy Policy](https://www.photoprism.app/privacy) and the following Code of Conduct. It explains the "dos and don’ts" when interacting with our team and other community members.**
**By using the software and services we provide, you agree to our [Terms of Service](https://www.photoprism.app/terms/), including our [Privacy Policy](https://www.photoprism.app/privacy/) and the following Code of Conduct. It explains the "dos and don’ts" when interacting with our team and other community members.**
*This Code of Conduct was last updated on May 6, 2024. A German translation is available at [photoprism.app/de/code-of-conduct](https://www.photoprism.app/de/code-of-conduct).*
*This Code of Conduct was last updated on May 30, 2026. A German translation is available at [photoprism.app/de/code-of-conduct](https://www.photoprism.app/de/code-of-conduct/).*
## Rules
@ -30,16 +30,12 @@ We have found that many of the issues that new users get upset about when they r
## Reporting
We encourage all community members to resolve problems on their own whenever possible. Serious and persistent violations, such as disrespectful, abusive, harassing, or otherwise unacceptable behavior, [may be reported](https://www.photoprism.app/contact) to us.
We encourage all community members to resolve problems on their own whenever possible. Serious and persistent violations, such as disrespectful, abusive, harassing, or otherwise unacceptable behavior, [may be reported](https://www.photoprism.app/contact/) to us.
## Enforcement
Our community standards will be enforced as necessary to protect everyone's well-being and to ensure that our discussion forums, chat rooms, and other infrastructure can be used as intended.
Our community standards will be enforced as necessary to protect everyone's well-being and ensure our discussion forums, chat rooms, and other infrastructure, such as GitHub, can be used as intended.
Initial warnings may be issued in the form of a [snarky comment](https://www.urbandictionary.com/define.php?term=snarky), especially if you seem reckless or [surprisingly harsh](https://github.com/photoprism/photoprism/issues/281#issuecomment-1207233135). In serious cases, we will provide a link to this Code of Conduct to avoid misunderstandings. We also reserve the right to delete rants, personal attacks, spam, and unsolicited advertising from our community forums.
In cases where violations may be unintentional and improvement seems possible, we aim to issue warnings before taking further action.
Getting a simple **\*plonk\***[^1] in response finally signals that we have lost hope and you're being ignored according to **Rule #3**. This old tradition from Usenet days is as time-saving as it is clear. It is not meant in a disrespectful way.
In addition, we may use technical measures to temporarily or permanently restrict your access to our infrastructure, including forums and chats.
[^1]: \*plonk\* including variants such as "Plonk." stands for the metaphorical sound of a user hitting the bottom of the kill file. It was first used in [Usenet forums](https://en.everybodywiki.com/Plonk_(Usenet)), a worldwide distributed discussion system and precursor to the Web.
In serious cases, we may use technical measures to restrict your access to our infrastructure, including GitHub, forums, and chats, either temporarily or permanently. We also reserve the right to delete rants, personal attacks, spam, and unsolicited advertisements. If you believe we made a mistake, you may [email us](https://www.photoprism.app/contact/) to request an appeal.
@ -4,9 +4,9 @@ We welcome contributions of any kind, including blog posts, tutorials, testing,
## Join the Community ##
Follow us on [Mastodon](https://floss.social/@photoprism), [Bluesky](https://bsky.app/profile/photoprism.app), or join the [Community Chat](https://link.photoprism.app/chat) to get regular updates, connect with other users, and discuss your ideas. Our [Code of Conduct](https://www.photoprism.app/code-of-conduct) explains the "dos and don’ts" when interacting with other community members.
Follow us on [Mastodon](https://floss.social/@photoprism), [Bluesky](https://bsky.app/profile/photoprism.app), or join the [Community Chat](https://link.photoprism.app/chat) to get regular updates, connect with other users, and discuss your ideas. Our [Code of Conduct](https://www.photoprism.app/code-of-conduct/) explains the "dos and don’ts" when interacting with other community members.
As a [contributor](https://docs.photoprism.app/developer-guide/), you are also welcome to [contact us directly](https://www.photoprism.app/contact) if you have something on your mind that you don't want to discuss publicly. Please note, however, that due to the high volume of emails we receive, our team may be unable to get back to you immediately. We do our best to respond within five business days or less.
As a [contributor](https://docs.photoprism.app/developer-guide/), you are also welcome to [contact us directly](https://www.photoprism.app/contact/) if you have something on your mind that you don't want to discuss publicly. Please note, however, that due to the high volume of emails we receive, our team may be unable to get back to you immediately. We do our best to respond within five business days or less.
## Not a Developer? No Problem ##
@ -36,20 +36,22 @@ We kindly ask you not to report bugs via GitHub Issues **unless you are certain
- When reporting a problem, always include the software versions you are using and other information about your environment such as [browser, browser plugins](https://docs.photoprism.app/getting-started/troubleshooting/browsers/), operating system, [storage type](https://docs.photoprism.app/getting-started/troubleshooting/performance/#storage), [memory size](https://docs.photoprism.app/getting-started/troubleshooting/performance/#memory), and [processor](https://docs.photoprism.app/getting-started/troubleshooting/performance/#server-cpu)
- Note that all issue **subscribers receive an email notification** from GitHub whenever a new comment is added, so these should only be used for sharing important information and not for discussions, questions or expressing personal opinions
- [Contact us](https://www.photoprism.app/contact) or [a community member](https://link.photoprism.app/discussions) if you need help, it could be a local configuration problem, or a misunderstanding in how the software works
- [Contact us](https://www.photoprism.app/contact/) or [a community member](https://link.photoprism.app/discussions) if you need help, it could be a local configuration problem, or a misunderstanding in how the software works
- This gives our team the opportunity to [improve the docs](https://docs.photoprism.app/getting-started/troubleshooting/) and provide best-in-class support to you, instead of handling unclear/duplicate bug reports or triggering a flood of notifications by responding to comments
## Submitting Pull Requests ##
Follow our [step-by-step guide](https://docs.photoprism.app/developer-guide/pull-requests) to learn how to submit new features, bug fixes, and documentation enhancements.
You are welcome to use AI tools for research, drafting, or other supporting tasks. However, we do not accept fully AI-generated pull requests at this time. Every contribution must be carefully reviewed, tested, and understood by a human contributor who can discuss the implementation details and maintain the change afterward.
Pull requests solving ["help wanted"](https://github.com/photoprism/photoprism/labels/help%20wanted) issues are the easiest to merge and the most helpful to us, as they allow us to spend more time on core functionality and other issues that are difficult for external contributors to work on. If you are new to this project, anything labeled ["easy"](https://github.com/photoprism/photoprism/labels/easy) may be a good first contribution.
**Be aware that reviewing, testing and finally merging pull requests requires significant resources on our side. It can therefore take several months if it is not just a small fix, especially if extensive testing is needed to prevent bugs from getting into our stable version.**
## Contributor License Agreement (CLA) ##
After you submit your first pull request, you will be asked to accept our Contributor License Agreement (CLA). Visit [photoprism.app/cla](https://www.photoprism.app/cla) and [photoprism.app/oss/faq](https://www.photoprism.app/oss/faq#cla) to learn more.
After you submit your first pull request, you will be asked to accept our Contributor License Agreement (CLA). Visit [photoprism.app/cla](https://www.photoprism.app/cla/) and [photoprism.app/oss/faq](https://www.photoprism.app/oss/faq/#cla) to learn more.
## Thank You to All Current and Past Sponsors 💎 ##
@ -74,4 +76,4 @@ Because many of these apps and tools were originally developed for internal use
----
*PhotoPrism® is a [registered trademark](https://www.photoprism.app/trademark). By using the software and services we provide, you agree to our [Terms of Service](https://www.photoprism.app/terms), [Privacy Policy](https://www.photoprism.app/privacy), and [Code of Conduct](https://www.photoprism.app/code-of-conduct). Docs are [available](https://link.photoprism.app/github-docs) under the [CC BY-NC-SA 4.0 License](https://creativecommons.org/licenses/by-nc-sa/4.0/); [additional terms](https://github.com/photoprism/photoprism/blob/develop/assets/README.md) may apply.*
*PhotoPrism® is a [registered trademark](https://www.photoprism.app/trademark/). By using the software and services we provide, you agree to our [Terms of Service](https://www.photoprism.app/terms/), [Privacy Policy](https://www.photoprism.app/privacy/), and [Code of Conduct](https://www.photoprism.app/code-of-conduct/). Docs are [available](https://link.photoprism.app/github-docs) under the [CC BY-NC-SA 4.0 License](https://creativecommons.org/licenses/by-nc-sa/4.0/); [additional terms](https://github.com/photoprism/photoprism/blob/develop/assets/README.md) may apply.*
- This is the single source of truth for terminology used across PhotoPrism documentation.
- Define terms once here and reference this file instead of redefining the same terms in multiple documents.
- Keep technical/API contract names unchanged where required, even when user-facing wording differs.
### Canonical Terms
- **admin** — user or client with elevated authorization scopes/roles.
- **AdvertiseUrl** — internal/service URL that cluster peers use to reach an instance or service.
- **app** — intentional identifier in names/examples (for example `app.js`, `app.kubernetes.io/*`, `photoprism-app`); not the preferred generic runtime role term.
- **client** — OAuth/API client identity and credentials (`ClientID`, `ClientSecret`), and broadly a caller of an API.
- **cluster domain** — DNS domain used to derive cluster defaults (for example portal/instance URLs).
- **cluster UUID** — stable cluster identifier used by provisioning and cluster metadata.
- **instance** — PhotoPrism runtime with role `instance` (a cluster member serving UI/API/media features).
- **Join Token** — bootstrap bearer token used for initial registration (`/api/v1/cluster/nodes/register`).
- **node** — technical identifier used in API/config contracts (for example `/api/v1/cluster/nodes`, `NodeName`, `PHOTOPRISM_NODE_*`, `config/node/...` paths).
- **portal** — PhotoPrism runtime with role `portal`, providing cluster control-plane APIs and routing.
- **service** — PhotoPrism runtime with role `service` (non-instance cluster member focused on service workloads).
- **SiteUrl** — canonical public URL/origin for an instance.
- **tenant** — shared-domain routing ownership label used in path-based URLs such as `/i/<tenant>/...`; typically maps to a registered instance name.
### Writing Rules
- Use **instance**/**instances** for cluster runtime behavior and role language.
- Use **tenant**/**tenants** for shared-domain path ownership and routing semantics.
- Keep **node** where a field name, endpoint, flag/env var, config path, or code contract explicitly requires it.
- Keep **app** only where it is an intentional identifier/example.
- When a term appears ambiguous, link or refer back to this glossary.
### Change Management
- Update this file first when introducing, renaming, or clarifying core terminology.
- When terminology changes, update dependent docs to match these definitions.
$(info Creating tar.gz archives from the directories in "$(BUILD_PATH)"...)
find "$(BUILD_PATH)" -maxdepth 1 -mindepth 1 -type d -name "photoprism*" -exec tar --exclude='.[^/]*' -C {} -czf {}.tar.gz . \;
pkg:pkg-amd64 pkg-arm64pkg-armv7
pkg:pkg-amd64 pkg-arm64
pkg-amd64:
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" photoprism/develop:jammy make all install tar.gz
PhotoPrism® is an AI-Powered Photos App for the [Decentralized Web](https://en.wikipedia.org/wiki/Decentralized_web).
It makes use of the latest technologies to tag and find pictures automatically without getting in your way.
You can run it at home, on a private server, or in the cloud.
PhotoPrism® is an AI-powered, privacy-first app for browsing, organizing, and sharing photos and videos. It helps tag, search, and rediscover media without getting in your way, whether self-hosted or in the cloud.
To get a first impression, you are welcome to play with our [public demo](https://try.photoprism.app/). Please be careful not to upload any private, unlawful or offensive pictures.
@ -20,23 +18,21 @@ To get a first impression, you are welcome to play with our [public demo](https:
**Our mission is to provide the most user- and privacy-friendly solution to keep your pictures organized and accessible.** That's why PhotoPrism was built from the ground up to run wherever you need it, without compromising freedom, privacy, or functionality:
* Browse [all your photos](https://docs.photoprism.app/user-guide/organize/browse/) and [videos](https://try.photoprism.app/library/videos) without worrying about [RAW conversion, duplicates or video formats](https://docs.photoprism.app/user-guide/settings/library/)
* Easily find specific pictures using [powerful search filters](https://try.photoprism.app/library/browse?view=cards&q=flower%20color%3Ared)
* Recognizes [the faces of your family and friends](https://try.photoprism.app/library/people)
* [Automatic classification](https://try.photoprism.app/library/labels) of pictures based on their content and location
* [Play Live Photos](https://try.photoprism.app/library/live) by hovering over them in [albums](https://try.photoprism.app/library/albums) and [search results](https://try.photoprism.app/library/browse?view=cards&q=type%3Alive)
* Since the [User Interface](https://try.photoprism.app/) is a [Progressive Web App](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps),
it provides a native app-like experience, and you can conveniently install it on the home screen of all major operating systems and mobile devices
* Includes four high-resolution [World Maps](https://try.photoprism.app/library/places) to bring back the memories of your favorite trips
* Metadata is extracted and merged from Exif, XMP, and other sources such as Google Photos
* Many more image properties like [Colors](https://try.photoprism.app/library/browse?view=cards&q=color:red), [Chroma](https://try.photoprism.app/library/browse?view=cards&q=mono%3Atrue), and [Quality](https://try.photoprism.app/library/review) can be searched as well
* Use [PhotoSync](https://link.photoprism.app/photosync) to securely backup iOS and Android phones in the background
* WebDAV clients such as Microsoft's Windows Explorer and Apple's Finder [can connect directly](https://docs.photoprism.app/user-guide/sync/webdav/) to PhotoPrism, allowing you to open, edit, and delete files from your computer as if they were local
Being completely [**self-funded and independent**](https://link.photoprism.app/membership), we can promise you that we will [never sell your data](https://www.photoprism.app/privacy) and that we will [always be transparent](https://www.photoprism.app/terms) about our software and services. Your data will never be shared with Google, Amazon, Microsoft or Apple unless you intentionally upload files to one of their services. 🔒
* Browse [all your pictures](https://docs.photoprism.app/user-guide/organize/browse/) without worrying about [RAW images](https://www.photoprism.app/kb/file-formats/) or [video formats](https://docs.photoprism.app/user-guide/organize/video/)
* Whether you're using a phone, tablet, or desktop computer, our [intuitive PWA](https://try.photoprism.app/) provides a native app-like experience and can be [easily installed](https://docs.photoprism.app/user-guide/pwa/) on your home screen
* Quickly find specific photos and videos with [powerful search filters](https://docs.photoprism.app/user-guide/search/filters/) that can be combined and are available for [many different properties](https://docs.photoprism.app/user-guide/search/filters/#filter-reference), including [labels](https://try.photoprism.app/library/labels), [location](https://try.photoprism.app/library/places?q=s2:47a85a63f764), [resolution](https://try.photoprism.app/library/browse?view=cards&q=mp:4), [color](https://try.photoprism.app/library/browse?view=cards&q=color:red), [chroma](https://try.photoprism.app/library/browse?view=cards&q=mono%3Atrue), and [quality](https://try.photoprism.app/library/review)
* [Automatically labels your pictures](https://try.photoprism.app/library/labels) based on content and location, and recognizes the faces of [your family and friends](https://try.photoprism.app/library/people/new)
* [Live Photos](https://try.photoprism.app/library/live) start playing when you [hover over them](https://try.photoprism.app/library/browse?view=cards&q=type%3Alive) and when viewing a slideshow
* Six high-resolution [World Maps](https://try.photoprism.app/library/places) and our [privacy-preserving geocoding service](https://docs.photoprism.app/getting-started/#maps-places) help bring back memories of your favorite trips and let you explore the world
* Metadata can be extracted and merged from Exif, XMP, and other sources like Google Photos
* [Use compatible apps](https://docs.photoprism.app/user-guide/native-apps/) like [PhotoSync](https://link.photoprism.app/photosync) to back up iOS and Android phones in the background
* WebDAV clients such as [Microsoft's Windows Explorer](https://docs.photoprism.app/user-guide/sync/webdav/#__tabbed_1_2) and [Apple's Finder](https://docs.photoprism.app/user-guide/sync/webdav/#connect-to-a-webdav-server) can [connect directly to PhotoPrism](https://docs.photoprism.app/user-guide/sync/webdav/), allowing you to open, edit, and delete files from your computer as if they were local
Being completely [**self-funded and independent**](https://link.photoprism.app/membership), we can promise you that we will [never sell your data](https://www.photoprism.app/privacy/) and that we will [always be transparent](https://www.photoprism.app/terms/) about our software and services. Your data will never be shared with Google, Amazon, Microsoft or Apple unless you intentionally upload files to one of their services. 🔒
Step-by-step [installation instructions](https://docs.photoprism.app/getting-started/) for our self-hosted [community edition](https://link.photoprism.app/personal-editions) can be found on [docs.photoprism.app](https://docs.photoprism.app/getting-started/) - all you need is a Web browser and [Docker](https://docs.docker.com/get-docker/) to run the server. It is available for Mac, Linux, and Windows.
@ -47,18 +43,18 @@ See our [Getting Started FAQ](https://docs.photoprism.app/getting-started/faq/#h
## Support Our Mission 💎 ##
**PhotoPrism is 100% self-funded and independent.** Your [continued support](https://link.photoprism.app/membership) helps us [provide more features to the public](https://www.photoprism.app/oss/faq#what-functionality-is-generally-available), release [regular updates](https://docs.photoprism.app/release-notes/), and remain independent!
**PhotoPrism is 100% self-funded and independent.** Your [continued support](https://link.photoprism.app/membership) helps us [provide more features to the public](https://www.photoprism.app/oss/faq/#what-functionality-is-generally-available), release [regular updates](https://docs.photoprism.app/release-notes/), and remain independent!
Our members [enjoy additional features](https://www.photoprism.app/kb/personal), including access to [interactive world maps](https://try.photoprism.app/library/places), and can join our private chat room to [connect with our team](https://www.photoprism.app/about/team). We currently have the following membership options:
Our members [enjoy additional features](https://www.photoprism.app/kb/personal/), including access to [interactive world maps](https://try.photoprism.app/library/places), and can join our private chat room to [connect with our team](https://www.photoprism.app/about/team/). We currently have the following membership options:
- You can [sign up directly on our website](https://link.photoprism.app/membership) and pay with credit card or SEPA through Stripe, so you don't need to [link an external account](https://www.photoprism.app/kb/activation) and can easily upgrade or downgrade at any time
- You can [sign up directly on our website](https://link.photoprism.app/membership) and pay with credit card or SEPA through Stripe, so you don't need to [link an external account](https://www.photoprism.app/kb/activation/) and can easily upgrade or downgrade at any time
- Alternatively, [Patreon](https://link.photoprism.app/patreon) also supports PayPal, additional currencies, and lets you choose between monthly and annual billing for all tiers
If you currently support us through [GitHub Sponsors](https://link.photoprism.app/sponsor), you can also [register on our website](https://my.photoprism.app/register) and use the *Activate GitHub Sponsors Membership* button to link your account. For details on this and how to [link your Patreon account](https://www.patreon.com/pledges), see our [Activation Guide](https://www.photoprism.app/kb/activation).
If you currently support us through [GitHub Sponsors](https://link.photoprism.app/sponsor), you can also [register on our website](https://my.photoprism.app/register) and use the *Activate GitHub Sponsors Membership* button to link your account. For details on this and how to [link your Patreon account](https://www.patreon.com/pledges), see our [Activation Guide](https://www.photoprism.app/kb/activation/).
You are [welcome to contact us](https://www.photoprism.app/contact) for change requests, membership questions, and business partnerships.
You are [welcome to contact us](https://www.photoprism.app/contact/) for change requests, membership questions, and business partnerships.
[View Membership FAQ ›](https://www.photoprism.app/kb/membership)[Sign Up ›](https://link.photoprism.app/membership)
[View Membership FAQ ›](https://www.photoprism.app/membership/faq/)[Sign Up ›](https://link.photoprism.app/membership)
### Why Your Support Matters ###
@ -79,26 +75,32 @@ Common problems can be quickly diagnosed and solved using our [Troubleshooting C
Our [Project Roadmap](https://link.photoprism.app/roadmap) shows what tasks are in progress and what features will be implemented next. You are invited to give ideas you like a thumbs-up, so we know what's most popular.
Be aware that we have a zero-bug policy and do our best to help users when they need support or have other questions. This comes at a price though, as we can't give exact release dates for new features. Our team receives many more requests than can be implemented, so we want to emphasize that we are in no way obligated to implement the features, enhancements, or other changes you request. We do, however, appreciate your feedback and carefully consider all requests.
**Because sustained funding is key to quickly releasing new features, we encourage you to support our mission by [signing up as a sponsor](https://link.photoprism.app/sponsor) or purchasing a [commercial license](https://www.photoprism.app/teams). Ultimately, that's what's best for the product and the community.**
**Because sustained funding is key to quickly releasing new features, we encourage you to support our mission by [signing up for a personal membership](https://link.photoprism.app/membership) or [purchasing a commercial license](https://www.photoprism.app/teams/#compare).**
[Become a Member ›](https://link.photoprism.app/membership)
## GitHub Issues ⚠️ ##
We kindly ask you not to report bugs via GitHub Issues **unless you are certain to have found a fully reproducible and previously unreported issue** that must be fixed directly in the app. Thank you for your careful consideration!
- When browsing issues, please note that **our team and all issue subscribers receive an email notification** from GitHub whenever a new comment is added, so these should only be used for sharing important information and not for [discussions, questions](https://github.com/photoprism/photoprism/discussions), or [expressing personal opinions](https://www.photoprism.app/code-of-conduct)
- In order for us to investigate [new bug reports](https://www.photoprism.app/kb/reporting-bugs), they must include **a complete list of steps to reproduce the problem**, the software versions used and information about the environment in which the problem occurred, such as [browser type, browser version, browser plug-ins](https://docs.photoprism.app/getting-started/troubleshooting/browsers/), operating system, [storage type](https://docs.photoprism.app/getting-started/troubleshooting/performance/#storage), [processor type](https://docs.photoprism.app/getting-started/troubleshooting/performance/#server-cpu), and [memory size](https://docs.photoprism.app/getting-started/troubleshooting/performance/#memory)
- [Contact us](https://www.photoprism.app/contact) or [a community member](https://link.photoprism.app/discussions) if you need help, it could be a local configuration problem, or a misunderstanding in how the software works
- When browsing issues, please note that **our team and all issue subscribers receive an email notification** from GitHub whenever a new comment is added, so these should only be used for sharing important information and not for [discussions, questions](https://github.com/photoprism/photoprism/discussions), or [expressing personal opinions](https://www.photoprism.app/code-of-conduct/)
- In order for us to investigate [new bug reports](https://www.photoprism.app/kb/reporting-bugs/), they must include **a complete list of steps to reproduce the problem**, the software versions used and information about the environment in which the problem occurred, such as [browser type, browser version, browser plug-ins](https://docs.photoprism.app/getting-started/troubleshooting/browsers/), operating system, [storage type](https://docs.photoprism.app/getting-started/troubleshooting/performance/#storage), [processor type](https://docs.photoprism.app/getting-started/troubleshooting/performance/#server-cpu), and [memory size](https://docs.photoprism.app/getting-started/troubleshooting/performance/#memory)
- [Contact us](https://www.photoprism.app/contact/) or [a community member](https://link.photoprism.app/discussions) if you need help, it could be a local configuration problem, or a misunderstanding in how the software works
- This gives us the opportunity to [improve our documentation](https://docs.photoprism.app/getting-started/troubleshooting/) and provide best-in-class support instead of dealing with unclear/duplicate bug reports or triggering a flood of notifications by replying to comments
## Connect with the Community ##
Follow us on [Mastodon](https://floss.social/@photoprism), [Bluesky](https://bsky.app/profile/photoprism.app), or join the [Community Chat](https://link.photoprism.app/chat) to get regular updates, connect with other users, and discuss your ideas. Our [Code of Conduct](https://www.photoprism.app/code-of-conduct) explains the "dos and don’ts" when interacting with other community members.
As a [contributor](CONTRIBUTING.md), you are also welcome to [contact us directly](https://www.photoprism.app/contact) if you have something on your mind that you don't want to discuss publicly. Please note, however, that due to the high volume of emails we receive, our team may be unable to get back to you immediately. We do our best to respond within five business days or less.
Follow us on [Mastodon](https://floss.social/@photoprism), [Bluesky](https://bsky.app/profile/photoprism.app), or join the [Community Chat](https://link.photoprism.app/chat) to get regular updates, connect with other users, and discuss your ideas. Our [Code of Conduct](https://www.photoprism.app/code-of-conduct/) explains the "dos and don’ts" when interacting with other community members.
As a [contributor](CONTRIBUTING.md), you are also welcome to [contact us directly](https://www.photoprism.app/contact/) if you have something on your mind that you don't want to discuss publicly. Please note, however, that due to the high volume of emails we receive, our team may be unable to get back to you immediately. We do our best to respond within five business days or less.
## Every Contribution Makes a Difference ##
@ -106,4 +108,4 @@ We welcome [contributions](CONTRIBUTING.md) of any kind, including blog posts, t
----
*PhotoPrism® is a [registered trademark](https://www.photoprism.app/trademark). By using the software and services we provide, you agree to our [Terms of Service](https://www.photoprism.app/terms), [Privacy Policy](https://www.photoprism.app/privacy), and [Code of Conduct](https://www.photoprism.app/code-of-conduct). Docs are [available](https://link.photoprism.app/github-docs) under the [CC BY-NC-SA 4.0 License](https://creativecommons.org/licenses/by-nc-sa/4.0/); [additional terms](https://github.com/photoprism/photoprism/blob/develop/assets/README.md) may apply.*
*PhotoPrism® is a [registered trademark](https://www.photoprism.app/trademark/). By using the software and services we provide, you agree to our [Terms of Service](https://www.photoprism.app/terms/), [Privacy Policy](https://www.photoprism.app/privacy/), and [Code of Conduct](https://www.photoprism.app/code-of-conduct/). Docs are [available](https://link.photoprism.app/github-docs) under the [CC BY-NC-SA 4.0 License](https://creativecommons.org/licenses/by-nc-sa/4.0/); [additional terms](https://github.com/photoprism/photoprism/blob/develop/assets/README.md) may apply.*
Your [continued support](https://link.photoprism.app/membership) helps us [provide more features to the public](https://www.photoprism.app/oss/faq#what-functionality-is-generally-available), release [regular updates](https://docs.photoprism.app/release-notes/), and remain independent! 💜
Your [continued support](https://link.photoprism.app/membership) helps us [provide more features to the public](https://www.photoprism.app/oss/faq/#what-functionality-is-generally-available), release [regular updates](https://docs.photoprism.app/release-notes/), and remain independent! 💜
You are [welcome to contact us](https://www.photoprism.app/contact) for change requests, membership questions, and business partnerships.
You are [welcome to contact us](https://www.photoprism.app/contact/) for change requests, membership questions, and business partnerships.
[View Membership FAQ ›](https://www.photoprism.app/kb/membership)[Contact Us ›](https://www.photoprism.app/contact)
[View Membership FAQ ›](https://www.photoprism.app/membership/faq/)[Contact Us ›](https://www.photoprism.app/contact/)
## Platinum Sponsors ##
@ -24,12 +24,16 @@ You are [welcome to contact us](https://www.photoprism.app/contact) for change r