40 KiB
PhotoPrism® — Repository Guidelines
Last Updated: December 8, 2025
Purpose
This file tells automated coding agents (and humans) where to find the single sources of truth for building, testing, and contributing to this repository. Visit https://agents.md/ to learn more.
Sources of Truth
- Makefile targets (always prefer existing targets): https://github.com/photoprism/photoprism/blob/develop/Makefile
- Developer Guide – Setup: https://docs.photoprism.app/developer-guide/setup/
- Developer Guide – Tests: https://docs.photoprism.app/developer-guide/tests/
- Contributing: https://github.com/photoprism/photoprism/blob/develop/CONTRIBUTING.md
- Security: https://github.com/photoprism/photoprism/blob/develop/SECURITY.md
- REST API: https://docs.photoprism.dev/ (Swagger), https://docs.photoprism.app/developer-guide/api/ (Docs)
- Code Maps:
CODEMAP.md(Backend/Go),frontend/CODEMAP.md(Frontend/JS) - Packages:
README.mdfiles underinternal/,pkg/, andfrontend/src/, e.g.internal/photoprism/README.md,internal/photoprism/batch/README.md,internal/config/README.md,internal/server/README.md,internal/api/README.md,internal/thumb/README.md,internal/ffmpeg/README.md, andfrontend/src/common/README.md. - Face Detection & Embeddings:
internal/ai/face/README.md - Vision Config & Engines:
internal/ai/vision/README.md,internal/ai/vision/openai/README.md,internal/ai/vision/ollama/README.md
Quick Tip: to inspect GitHub issue details without leaving the terminal, run
curl -s https://api.github.com/repos/photoprism/photoprism/issues/<id>.
Specifications, Versioning, & Writing Style
- In the main repo,
specs/and other directories may appear to be ignored because they are nested Git repositories; if so, change directories before staging or committing updates. - Availability: The
specs/repository is private and is not guaranteed to be present in every clone or environment. Do not addMakefiletargets in the main project that depend onspecs/paths. Whenspecs/is available, you MAY run its tools manually (e.g.,bash specs/scripts/lint-status.sh), but the main repo must remain buildable withoutspecs/.- If available, always use the latest spec version for a topic (highest
-vN), as linked fromspecs/README.md. - Testing Guides:
specs/dev/backend-testing.md(Backend/Go),specs/dev/frontend-testing.md(Frontend/JS) - Whenever the Change Management instructions for a document require it, publish changes as a new file with an incremented version suffix (e.g.,
*-v3.md) rather than overwriting the original file. - Older spec versions remain in the repo for historical reference but are not linked from the main TOC. Do not base new work on superseded files (e.g.,
*-v1.mdwhen*-v2.mdexists). - Auto-generated configuration and command references live under
specs/generated/. Agents MUST NOT read, analyse, or modify anything in this directory; refer humans tospecs/generated/README.mdif regeneration is required.
- If available, always use the latest spec version for a topic (highest
- Regenerate
NOTICEfiles withmake noticewhen dependencies change (e.g., updates togo.mod,go.sum,package-lock.json, or other lockfiles). Do not editNOTICEorfrontend/NOTICEmanually. - When writing CLI examples or scripts, place option flags before positional arguments unless the command requires a different order.
Document headings must use Title Case (in APA or AP style) across Markdown files to keep generated navigation and changelogs consistent. Always spell the product name as
PhotoPrism; this proper noun is an exception to generic naming rules.
Safety & Data
- If
git statusshows unexpected changes, assume a human might be editing; if you think you caused them, ask for permission before using reset commands likegit checkoutorgit reset. - Do not run
git config(global or repo-level); changing Git configuration is prohibited for agents. - 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.gocacheare ignored in.gitignoreand.dockerignore. - Prefer using existing caches, workers, and batching strategies referenced in code and
Makefile. - Consider memory/CPU impact of changes; only suggest benchmarks or profiling when justified.
If anything in this file conflicts with the
Makefileor Sources of Truth, ask for clarification before proceeding.
Project Structure & Languages
- Backend: Go (
internal/,pkg/,cmd/) + MariaDB/SQLite- Package boundaries: Code in
pkg/*MUST NOT import frominternal/*. - If you need access to config/entity/DB, put new code in a package under
internal/instead ofpkg/.
- Package boundaries: Code in
- GORM field naming: When adding struct fields that include uppercase abbreviations (e.g.,
LabelNSFW,UserID,URLHash), set an explicitgorm:"column:<name>"tag so column names stay consistent (label_nsfw,user_id,url_hashinstead of split-letter variants). - Frontend: Vue 3 + Vuetify 3 (
frontend/) - Docker/compose for dev/CI; Traefik is used for local TLS (
*.localssl.dev)
Web Templates & Shared Assets
- HTML entrypoints live under
assets/templates/; key files areindex.gohtml,app.gohtml,app.js.gohtml, andsplash.gohtml. The browser check logic resides inassets/static/js/browser-check.jsand is included viaapp.js.gohtml; it performs capability checks (Promise, fetch, AbortController,script.noModule, etc.) before the main bundle executes. - To preserve the fallback messaging, keep the
<script>order inapp.js.gohtmlsobrowser-check.jsloads before{{ .config.JsUri }}. Do not adddeferorasyncto the bundle tag unless you reintroduce a guarded loader. - The same loader partial is reused in private packages (
pro/assets/templates/index.gohtml,plus/assets/templates/index.gohtml). Whenever you touchapp.js.gohtmlor change how we load the bundle, mirror the update by running commands such ascd pro && sed -n '1,160p' assets/templates/index.gohtml(and similarly forplus) to confirm they include the shared partial instead of hard-coding<script src="{{ .config.JsUri }}">. - Splash styles are defined in
frontend/src/css/splash.css. Add new splash elements (for example.splash-warning) there so both public and private editions remain visually consistent. - Browser baseline: PhotoPrism requires Safari 13 / iOS 13 or current Chrome, Edge, or Firefox. Update the message in
assets/templates/app.js.gohtml(and the matching CSS) if support changes.
Agent Runtime (Host vs Container)
Agents MAY run either:
- Inside the Development Environment container (recommended for least privilege).
- On the host (outside Docker), in which case the agent MAY start/stop the Dev Environment as needed.
Detecting the environment (agent logic)
Agents SHOULD detect the runtime and choose commands accordingly:
- Inside container if
/.dockerenvexists (authoritative signal). - Path hint: when the project path is
/go/src/github.com/photoprism/photoprismand/.dockerenvis absent, assume you are on the host with a bind mount; treat it as host mode and prefer host-side Docker commands.
Examples
Bash:
if [ -f "/.dockerenv" ]; then
echo "container"
else
echo "host"
fi
Node.js:
const fs = require("fs");
const inContainer = fs.existsSync("/.dockerenv");
console.log(inContainer ? "container" : "host");
Agent installation and invocation
-
Inside container: Prefer running agents via
npm exec(no global install), for example:npm exec --yes <agent-binary> -- --help- Or use
npx <agent-binary> ... - If the agent is distributed via npm and must be global, install inside the container only:
npm install -g <agent-npm-package>
- Replace
<agent-binary>/<agent-npm-package>with the names from the agent’s official docs.
-
On host: Use the vendor’s recommended install for your OS. Ensure your agent runs from the repository root so it can discover
AGENTS.mdand project files.
Build & Run (local)
-
Run
make helpto see common targets (or open theMakefile). -
Host mode (agent runs on the host; agent MAY manage Docker lifecycle):
- Build local dev image (once):
make docker-build - Start services:
docker compose up(add-dto start in the background) - Follow live app logs:
docker compose logs -f --tail=100 photoprism(Ctrl+C to stop)- All services:
docker compose logs -f --tail=100 - Last 10 minutes only:
docker compose logs -f --since=10m photoprism - Plain output (easier to copy):
docker compose logs -f --no-log-prefix --no-color photoprism
- All services:
- Execute a single command in the app container:
docker compose exec photoprism <command>- Example:
docker compose exec photoprism ./photoprism help - Why
./photoprism? It runs the locally built binary in the project directory. - Run as non-root to avoid root-owned files on bind mounts:
docker compose exec -u "$(id -u):$(id -g)" photoprism <command> - Durable alternative: set the service user or
PHOTOPRISM_UID/PHOTOPRISM_GIDincompose.yaml; if you hit issues, runmake fix-permissions.
- Example:
- Open a terminal session in the app container:
make terminal - Stop everything when done:
docker compose --profile=all down --remove-orphans(make downdoes the same)
- Build local dev image (once):
-
Container mode (agent runs inside the app container):
- Install deps:
make dep - Build frontend/backend:
make build-jsandmake build-go - Watch frontend changes (auto-rebuild):
make watch-js- Or run directly:
cd frontend && npm run watch - Tips: refresh the browser to see changes; running the watcher outside the container can be faster on non-Linux hosts; stop with Ctrl+C
- Or run directly:
- Start the PhotoPrism server:
./photoprism start- Open http://localhost:2342/ (HTTP)
- Or https://app.localssl.dev/ (HTTPS via Traefik reverse proxy)
- Only if Traefik is running and the dev compose labels are active
- Labels for
*.localssl.devare defined in the dev compose files, e.g. https://github.com/photoprism/photoprism/blob/develop/compose.yaml
- Admin Login: Local compose files set
PHOTOPRISM_ADMIN_USER=adminandPHOTOPRISM_ADMIN_PASSWORD=photoprism; if the credentials differ, inspectcompose.yaml(or the active environment) for these variables before logging in. - Do not use the Docker CLI inside the container; starting/stopping services requires host Docker access. If you need to manage compose while inside the dev container, switch to host mode (or ask a human) instead of running
docker composethere.
- Install deps:
Note: Across our public documentation, official images, and in production, the command-line interface (CLI) name is photoprism. Other PhotoPrism binary names are only used in development builds for side-by-side comparisons of the Community Edition (CE) with PhotoPrism Plus (photoprism-plus) and PhotoPrism Pro (photoprism-pro).
Operating Systems & Architectures
- Our guides and command examples generally assume the use of a Linux/Unix shell on a 64-bit AMD64 or ARM64 system.
- For Windows-specifics, see the Developer Guide FAQ: https://docs.photoprism.app/developer-guide/faq/#can-your-development-environment-be-used-under-windows
Code Style & Lint
- Go: run
make fmt-go swag-fmtto reformat the backend code + Swagger annotations (seeMakefilefor additional targets)- Run
make lint-go(golangci-lint) after Go changes; prefergolangci-lint run ./internal/<pkg>/...for focused edits. - 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.
- All newly added functions, including unexported helpers, must have a concise doc comment that explains their behavior.
- For short examples inside comments, indent code rather than using backticks; godoc treats indented blocks as preformatted.
- Run
- Branding: Always spell the product name as
PhotoPrism; this proper noun is an exception to generic naming rules. - Every Go package must contain a
<package>.gofile in its root (for example,internal/auth/jwt/jwt.go) with the standard license header and a short package description comment explaining its purpose. - JS/Vue: use the lint/format scripts in
frontend/package.json(ESLint + Prettier) - All added code and tests must be formatted according to our standards.
Remember to update the
**Last Updated:**line at the top whenever you edit these guidelines or other files containing a timestamp.
Tests
- From within the Development Environment:
- Full unit test suite:
make test(runs backend and frontend tests) - Test frontend/backend:
make test-jsandmake test-go - Linting:
make lint(all),make lint-go(golangci-lint with.golangci.yml, prints findings without failing due to--issues-exit-code 0),make lint-js(ESLint/Prettier) - Go packages:
go test(all tests) orgo test -run <name>(specific tests only)
- Full unit test suite:
- Need to inspect the MariaDB data while iterating? Connect directly inside the dev shell with
mariadb -D photoprismand run SQL without rebuilding Go code. - Go tests live beside sources: for
path/to/pkg/<file>.go, add tests inpath/to/pkg/<file>_test.go(create if missing). For the same function, group related cases ast.Run(...)sub-tests (table-driven where helpful) and use PascalCase for subtest names (for example,t.Run("Success", ...)). - Frontend unit tests use Vitest; see scripts in
frontend/package.json.- Vitest watch/coverage:
make vitest-watchandmake vitest-coverage
- Vitest watch/coverage:
- Acceptance tests: use the
acceptance-*targets in theMakefile
Playwright MCP Usage
- Endpoint & Navigation — Playwright MCP is preconfigured to reach the dev server at
http://localhost:2342/. Useplaywright__browser_navigateto open/library/login, sign in, and then callplaywright__browser_take_screenshotto capture the page state. - Viewport Defaults — Desktop sessions open with a
1280×900viewport by default. Useplaywright__browser_resizeif the viewport is not preconfigured or you need to adjust it mid-run. - Mobile Workflows — When testing responsive layouts, use the
playwright_mobileserver (for example,playwright_mobile__browser_navigate).
It launches with a375×667viewport, matching a typical smartphone display, so you can capture mobile layouts without manual resizing. - Authentication — Default admin credentials are
admin/photoprism:- If login fails, check your active Compose file or container environment for
PHOTOPRISM_ADMIN_USERandPHOTOPRISM_ADMIN_PASSWORD. - Tip: if your MCP supports it, persist a storage state after login and reuse it in later steps to skip re-authentication.
- If login fails, check your active Compose file or container environment for
- Sidebar Navigation — The sidebar nests items such as
Library → Errors:- Expand a parent entry by clicking its chevron before selecting links inside.
- Session Cleanup — After scripted interactions, close the browser tab with
playwright__browser_close(orplaywright_mobile__browser_close) to keep the MCP session tidy for subsequent runs. - Stability / Waiting — Prefer robust waits over sleeps:
- After navigation:
waitUntil: 'networkidle'(or wait for a key locator). - Before clicking: ensure the locator is
visibleandenabled. - Use role/label/text selectors over brittle XPaths.
- After navigation:
- Screenshot Format & Size — Keep artifacts small and reproducible:
- Prefer JPEG with quality (e.g.,
quality: 80) instead of PNG. - Limit to the visible viewport (
fullPage: false), unless explicitly required. - Name files deterministically, e.g.,
.local/screenshots/<case>/<step>__<viewport>.jpg(create the folder if it doesn’t exist). - Avoid embedding large screenshots in chat history—reference the file path instead.
- Desktop example (if your MCP tool exposes Playwright options 1:1):
{ "path": ".local/screenshots/fix-event-leaks/login__desktop.jpg", "type": "jpeg", "quality": 80, "fullPage": false }
- Prefer JPEG with quality (e.g.,
- Non-interactive runs — If
npxis fetching the MCP server at runtime, add--yesto its args (or preinstall and use--no-install) to avoid prompts in CI.
FFmpeg Tests & Hardware Gating
- By default, do not run GPU/HW encoder integrations in CI. Gate with
PHOTOPRISM_FFMPEG_ENCODER(one of:vaapi,intel,nvidia). - Negative-path tests should remain fast and always run:
- Missing ffmpeg binary → immediate exec error.
- Unwritable destination → command fails without creating files.
- Prefer command-string assertions when hardware is unavailable; enable HW runs locally only when a device is configured.
Fast, Focused Test Recipes
- Filesystem + archives (fast):
go test ./pkg/fs -run 'Copy|Move|Unzip' -count=1 - Media helpers (fast):
go test ./pkg/media/... -count=1 - Thumbnails (libvips, moderate):
go test ./internal/thumb/... -count=1 - FFmpeg command builders (moderate):
go test ./internal/ffmpeg -run 'Remux|Transcode|Extract' -count=1
CLI Testing Gotchas (Go)
- Exit codes and
os.Exit:urfave/clicallsos.Exit(code)when a command returnscli.Exit(...), which will terminatego testabruptly (often after logs likehttp 401:).- Use the test helper
RunWithTestContext(ininternal/commands/commands_test.go) which temporarily overridescli.OsExiterso the process doesn’t exit; you still receive the error to assertExitCoder. - If you only need to assert the exit code and don’t need printed output, you can invoke
cmd.Action(ctx)directly and checkerr.(cli.ExitCoder).ExitCode().
- Non‑interactive mode: set
PHOTOPRISM_CLI=noninteractiveand/or pass--yesto avoid prompts that block tests and CI. - SQLite DSN in tests:
config.NewTestConfig("<pkg>")defaults to SQLite with a per‑suite DSN like.<pkg>.db. Don’t assert an empty DSN for SQLite.- Clean up any per‑suite SQLite files in tests with
t.Cleanup(func(){ _ = os.Remove(dsn) })if you capture the DSN.
Frontend Focus Management
- Dialogs must follow the shared focus pattern documented in
frontend/src/common/README.md. - Always expose
ref="dialog"on<v-dialog>overlays, call$view.enter/leavein@after-enter/@after-leave, and avoid positivetabindexvalues. - Persistent dialogs (those with the
persistentprop) must handle Escape via@keydown.esc.exactso Vuetify’s default rejection animation is suppressed; keep other shortcuts on@keyupso inner inputs can cancel them first. - Global shortcuts run through
onShortCut(ev)incommon/view.js; it only forwards Escape andctrl/metacombinations, so do not rely on it for arbitrary keys. - When a dialog opens nested menus (for example, combobox suggestion lists), ensure they work with the global trap; see the README for troubleshooting tips.
Filesystem Permissions & io/fs Aliasing (Go)
- Always use our shared permission variables from
pkg/fswhen creating files/directories:- Directories:
fs.ModeDir(0o755 with umask) - Regular files:
fs.ModeFile(0o644 with umask) - Config files:
fs.ModeConfigFile(default 0o664) - Secrets/tokens:
fs.ModeSecretFile(default 0o600) - Backups:
fs.ModeBackupFile(default 0o600)
- Directories:
- Do not pass stdlib
io/fsflags (e.g.,fs.ModeDir) to functions expecting permission bits.- When importing the stdlib package, alias it to avoid collisions:
iofs "io/fs"orgofs "io/fs". - Our package is
github.com/photoprism/photoprism/pkg/fsand provides the only approved permission constants foros.MkdirAll,os.WriteFile,os.OpenFile, andos.Chmod.
- When importing the stdlib package, alias it to avoid collisions:
- Prefer
filepath.Joinfor filesystem paths; reservepath.Joinfor URL paths.
File I/O — Overwrite Policy (force semantics)
- Default is safety-first: callers must not overwrite non-empty destination files unless they opt-in with a
forceflag. - Replacing empty destination files is allowed without
force=true(useful for placeholder files). - Open destinations with
O_WRONLY|O_CREATE|O_TRUNCto avoid trailing bytes when overwriting; useO_EXCLwhen the caller must detect collisions. - Where this lives:
- App-level helpers:
internal/photoprism/mediafile.go(MediaFile.Copy/Move). - Reusable utils:
pkg/fs/copy.go,pkg/fs/move.go.
- App-level helpers:
- 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
- Always validate ZIP entry names with a safe join; reject:
- absolute paths (e.g.,
/etc/passwd). - Windows drive/volume paths (e.g.,
C:\\…orC:/…). - any entry that escapes the target directory after cleaning (path traversal via
..).
- absolute paths (e.g.,
- 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). - Tests to keep:
- Absolute/volume paths rejected (Windows-specific backslash path covered on Windows).
..traversal skipped;__MACOSXskipped.- Per-file and total size limits enforced; directory entries created; nested paths extracted safely.
HTTP Download — Security Checklist
- Use the shared safe HTTP helper instead of ad‑hoc
net/httpcode:- Package:
pkg/http/safe→safe.Download(destPath, url, *safe.Options). - Default policy in this repo: allow only
http/https, enforce timeouts and max size, write to a0600temp file then rename.
- Package:
- SSRF protection (mandatory unless explicitly needed for tests):
- Set
AllowPrivate=falseto block private/loopback/multicast/link‑local ranges. - All redirect targets are validated; the final connected peer IP is also checked.
- Prefer an image‑focused
Acceptheader for image downloads:"image/jpeg, image/png, */*;q=0.1".
- Set
- Avatars and small images: use the thin wrapper in
internal/thumb/avatar.SafeDownloadwhich applies stricter defaults (15s timeout, 10 MiB,AllowPrivate=false). - Tests using
httptest.Serveron 127.0.0.1 must passAllowPrivate=trueexplicitly to succeed. - Keep per‑resource size budgets small; rely on
io.LimitReader+Content-Lengthprechecks.
Agent Quick Tips (Do This)
Testing & Fixtures
- Go tests live next to their sources (
path/to/pkg/<file>_test.go); group related cases ast.Run(...)sub-tests to keep table-driven coverage readable, and name each subtest with a PascalCase string. - Keep Go scratch work inside
internal/...; Go refuses to importinternal/packages from directories like/tmp, so create temporary helpers under a throwaway folder such asinternal/tmp/instead of using external paths. - Prefer focused
go testruns for speed (go test ./internal/<pkg> -run <Name> -count=1,go test ./internal/commands -run <Name> -count=1) and avoid./...unless you need the entire suite. - Heavy packages such as
internal/entityandinternal/photoprismrun migrations and fixtures; expect 30–120s on first run and narrow with-runto keep iterations low. - For CLI-driven tests, wrap commands with
RunWithTestContext(cmd, args)sourfave/clicannot exit the process, and assert CLI output withassert.Contains/regex becauseshowreports quote strings. - In
internal/photoprismtests, rely onphotoprism.Config()for runtime-accurate behavior; only build a new config if you replace it viaphotoprism.SetConfig. - Generate identifiers with
rnd.GenerateUID(entity.ClientUID)for OAuth client IDs andrnd.UUIDv7()for node UUIDs; treatnode.uuidas required in responses. - When creating or editing shell scripts, run
shellcheck <file>(or the relevantmaketarget) and resolve warnings before exiting the task. - When adding persistent fixtures (photos, files, labels, etc.), always obtain new IDs via
rnd.GenerateUID(...)with the matching prefix (entity.PhotoUID,entity.FileUID,entity.LabelUID, …) instead of inventing manual strings so the search helpers recognize them. - For database updates, prefer the
entity.Valuestype alias over rawmap[string]interface{}so helpers stay type-safe and consistent with existing code. - Reach for
config.NewMinimalTestConfig(t.TempDir())when a test only needs filesystem/config scaffolding, and useconfig.NewMinimalTestConfigWithDb("<name>", t.TempDir())when you need a fresh SQLite schema without the cached fixture snapshot. - Config test helpers now auto-discover the repo
assets/directory; you should not setPHOTOPRISM_ASSETS_PATHmanually in packageinit()functions unless you have a non-standard layout. - Hub API traffic is disabled in tests by default via
hub.ApplyTestConfig(); opt back in withPHOTOPRISM_TEST_HUB=test. - Avoid
config.TestConfig()in new tests unless you truly need the fully seeded fixture set: it shares a singleton instance that runsInitializeTestData()and wipesstorage/testdata. Tests that write to Originals/Import (e.g. WebDAV helpers) should instead callconfig.NewMinimalTestConfig(t.TempDir())(or the DB variant) and follow up withconf.CreateDirectories()so they operate on an isolated sandbox. - Shared fixtures live under
storage/testdata;NewTestConfig("<pkg>")already callsInitializeTestData(), but callc.InitializeTestData()(and optionallyc.AssertTestData(t)) when you construct custom configs so originals/import/cache/temp exist.InitializeTestData()clears old data, downloads fixtures if needed, then callsCreateDirectories(). PhotoFixtures.Get()and similar helpers return value copies; when a test needs the database-backed row (with associations preloaded), re-query by UID/ID using helpers likeentity.FindPhoto(fixture)so updates observe persisted IDs and in-memory caches stay coherent.- For slimmer tests that only need config objects, prefer the new helpers in
internal/config/test.go:NewMinimalTestConfig(t.TempDir())when no database is needed, orNewMinimalTestConfigWithDb("<pkg>", t.TempDir())to spin up an isolated SQLite schema without seeding all fixtures. - When you need illustrative credentials (join tokens, client IDs/secrets, etc.), reuse the shared
Example*constants (seeinternal/service/cluster/examples.go) so tests, docs, and examples stay consistent.
Roles & ACL
- Map roles via the shared tables: users through
acl.ParseRole(s)/acl.UserRoles[...], clients throughacl.ClientRoles[...]. - Treat
RoleAliasNone("none") and an empty string asRoleNone; no caller-specific overrides. - Default unknown client roles to
RoleClient;acl.ParseRolealready handles0/false/nilas none for users. - Build CLI role help from
Roles.CliUsageString()(e.g.,acl.ClientRoles.CliUsageString()); never hand-maintain role lists. - When checking JWT/client scopes, use the shared helpers (
acl.ScopePermits/acl.ScopeAttrPermits) instead of hand-written parsing.
Import/Index
- ImportWorker may skip files if an identical file already exists (duplicate detection). Use unique copies or assert DB rows after ensuring a non‑duplicate destination.
- Mixed roots: when testing related files, keep
ExamplesPath()/ImportPath()/OriginalsPath()consistent soRelatedFilesandAllowExtbehave as expected. IndexOptions*helpers now require a*config.Config; pass the active config (orconfig.NewMinimalTestConfig(t.TempDir())in unit tests) so face/label/NSFW scheduling matches the current run.
CLI Usage & Assertions
- Prefer the shared helpers like
DryRunFlag(...)andYesFlag()when adding new CLI flags so behaviour stays consistent across commands. - Wrap CLI tests in
RunWithTestContext(cmd, args)sourfave/clicannot exit the process; assert quotedshowoutput withassert.Contains/regex for the trailing ", or " rule. - Prefer
--jsonresponses for automation.photoprism show commands --json [--nested]exposes the tree view (add--allfor hidden entries). - Use
internal/commands/catalogto inspect commands/flags without running the binary; when validating large JSON docs, marshal DTOs viacatalog.BuildFlat/BuildNodeinstead of parsing CLI stdout. - Expect
showcommands to return arrays of snake_case rows, exceptphotoprism show config, which yields{ sections: [...] }, and theconfig-options/config-yamlvariants, which flatten to a top-level array.
API & Config Changes
- Respect precedence:
options.ymloverrides CLI/env values, which override defaults. When adding a new option, updateinternal/config/options.go(yaml/flag tags), register it ininternal/config/flags.go, expose a getter, surface it in*config.Report(), and write generated values back tooptions.ymlby settingc.options.OptionsYamlbefore persisting. UseCliTestContextininternal/config/test.goto exercise new flags. - Use
pkg/fs.ConfigFilePathwhen you need a config filename so existing.ymlfiles remain valid and new installs can adopt.yamltransparently (the helper also covers other paired extensions such as.toml/.tml). - When touching configuration in Go code, use the public accessors on
*config.Config(e.g.Config.JWKSUrl(),Config.SetJWKSUrl(),Config.ClusterUUID()) instead of mutatingConfig.Options()directly; reserve raw option tweaks for test fixtures only. - When introducing new metadata sources (e.g.,
SrcOllama,SrcOpenAI), define them in bothinternal/entity/src.goand the frontend lookup tables (frontend/src/common/util.js) so UI badges and server priorities stay aligned. - Vision worker scheduling is controlled via
VisionSchedule/VisionFilterand theRunproperty set invision.yml. Utilities likevision.FilterModelsandentity.Photo.ShouldGenerateLabels/Captionhelp decide when work is required before loading media files. - Logging: use the shared logger (
event.Log) via the package-levellogvariable (seeinternal/auth/jwt/logger.go) instead of directfmt.Print*or ad-hoc loggers. - Audit outcomes: import
github.com/photoprism/photoprism/pkg/log/statusand end everyevent.Audit*slice with a single outcome token such asstatus.Succeeded,status.Failed,status.Denied, or other constants defined there (no additional segments afterwards). - Error outcomes: when a sanitized error string should be the outcome, call
status.Error(err)instead of adding a placeholder and passingclean.Error(err)manually. - Cluster registry tests (
internal/service/cluster/registry) currently rely on a full test config because they persistentity.Clientrows. They run migrations and seed the SQLite DB, so they are intentionally slow. If you refactor them, consider sharing a singleconfig.TestConfig()across subtests or building a lightweight schema harness; do not swap to the minimal config helper unless the tests stop touching the database. - Favor explicit CLI flags: check
c.cliCtx.IsSet("<flag>")before overriding user-supplied values, and follow theClusterUUIDpattern (options.yml→ CLI/env → generated UUIDv4 persisted). - Database helpers: reuse
conf.Db()/conf.Database*(), avoid GORMWithContext, quote MySQL identifiers, and reject unsupported drivers early. - Handler conventions: reuse limiter stacks (
limiter.Auth,limiter.Login) andlimiter.AbortJSONfor 429s, lean onapi.ClientIP,header.BearerToken, andAbort*helpers, compare secrets with constant time checks, setCache-Control: no-storeon sensitive responses, and register routes ininternal/server/routes.go. For new list endpoints defaultcount=100(max 1000) andoffset≥0, document parameters explicitly, and set portal mode viaPHOTOPRISM_NODE_ROLE=portalplusPHOTOPRISM_JOIN_TOKENwhen needed. - Swagger & docs: annotate only routed handlers in
internal/api/*.go, use full/api/v1/...paths, skip helpers, and regenerate docs withmake fmt-go swag-fmt swagormake swag-json(which also strips duplicatetime.Durationenums). When iterating, target packages withgo test ./internal/api -run Cluster -count=1or similarly scoped runs. - Testing helpers: isolate config paths with
t.TempDir(), reuseNewConfig,CliTestContext, andNewApiTest()harnesses, authenticate viaAuthenticateAdmin,AuthenticateUser, orOAuthToken, toggle auth withconf.SetAuthMode(config.AuthModePasswd), and prefer OAuth client tokens over non-admin fixtures for negative permission checks. - Registry data and secrets: store portal/node registry files under
conf.PortalConfigPath()/nodes/with mode0600, keep secrets out of logs, and only return them on creation/rotation flows.
Formatting (Go)
- Go is formatted by
gofmtand uses tabs. Do not hand-format indentation. - Always run after edits:
make fmt-go(gofmt + goimports).
API Shape Checklist
- When renaming or adding fields:
- Update DTOs in
internal/service/cluster/response.goand 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' -Sacross code, tests, and specs.
- Update DTOs in
API/CLI Tests: Known Pitfalls
- Gin routes: Register
CreateSession(router)once per test router; reusing it twice panics on duplicate route. - CLI commands: Some commands defer
conf.Shutdown()or emit signals that close the DB. The harness re‑opens DB before each run, but avoid invokingstartor emitting signals in unit tests. - Signals:
internal/commands/start.gowaits onprocess.Signal; callingprocess.Shutdown()/Restart()can close DB. Prefer not to trigger signals in tests.
Download CLI Workbench (yt-dlp, remux, importer)
-
Code anchors
- CLI flags and examples:
internal/commands/download.go - Core implementation (testable):
internal/commands/download_impl.go - yt-dlp helpers and arg wiring:
internal/photoprism/dl/*(options.go,info.go,file.go,meta.go) - Importer entry point:
internal/photoprism/get/import.go; options:internal/photoprism/import_options.go
- CLI flags and examples:
-
Quick test runs (fast feedback)
- 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
- yt-dlp package:
-
FFmpeg-less tests
- In tests: set
c.Options().FFmpegBin = "/bin/false"andc.Settings().Index.Convert = falseto avoid ffmpeg dependencies when not validating remux.
- In tests: set
-
Stubbing yt-dlp (no network)
- Use a tiny shell script that:
- prints minimal JSON for
--dump-single-json - creates a file and prints its path when
--printis requested
- prints minimal JSON for
- Harness env vars (supported by our tests):
YTDLP_ARGS_LOG— append final args for assertionYTDLP_OUTPUT_FILE— absolute file path to create for--printYTDLP_DUMMY_CONTENT— file contents to avoid importer duplicate detection between tests
- Use a tiny shell script that:
-
Remux policy and metadata
- Pipe method: PhotoPrism remux (ffmpeg) always embeds title/description/created.
- File method: yt‑dlp writes files; we pass
--postprocessor-args 'ffmpeg:-metadata creation_time=<RFC3339>'so imports getCreatedeven without local remux (fallback fromupload_date/release_date). - Default remux policy:
auto; usealwaysfor the most complete metadata (chapters, extended tags). - CLI defaults:
photoprism dlnow defaults to--method pipeand--impersonate firefox; pass-i noneto disable impersonation. Pipe mode streams raw media and PhotoPrism handles the final FFmpeg remux so metadata (title, description, author, creation time) still comes fromRemuxOptionsFromInfo.
-
Testing workflow: lean on the focused commands above; if importer dedupe kicks in, vary bytes with
YTDLP_DUMMY_CONTENTor adjustdest, and rememberinternal/photoprismis heavy so validate downstream packages first.
Sessions & Redaction (building sessions in tests)
- Admin session (full view):
AuthenticateAdmin(app, router). - User session: Create a non‑admin test user (role=guest), set a password, then
AuthenticateUser. - Client session (redacted internal fields;
SiteUrlvisible):Admins sees, _ := entity.AddClientSession("test-client", conf.SessionMaxAge(), "cluster", authn.GrantClientCredentials, nil) token := s.AuthToken() r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", token)AdvertiseUrlandDatabase; client/user sessions don’t.SiteUrlis safe to show to all roles.
Preflight Checklist
go build ./...make fmt-go swag-fmt swaggo test ./internal/service/cluster/registry -count=1go test ./internal/api -run 'Cluster' -count=1go test ./internal/commands -run 'ClusterRegister|ClusterNodesRotate' -count=1- Tooling constraints:
make swagmay fetch modules, so confirm network access before running it.
Cluster Operations
- Keep bootstrap code decoupled: avoid importing
internal/service/cluster/node/*frominternal/configor the cluster root, let nodes talk to the Portal over HTTP(S), and rely on constants frominternal/service/cluster/const.go. - Bootstrap refreshes node OAuth credentials on 401/403 responses (rotate secret + retry) and logs the refresh at info level; if the secret file cannot be written, the value stays cached in memory so the current process can continue.
- Portal validation now accepts HTTP advertise URLs only for loopback hosts or cluster-internal domains (
*.svc,*.cluster.local,*.internal); everything else must use HTTPS. - Config init order: load
options.yml(c.initSettings()), runEarlyExt().InitEarly(c), connect/register the DB, then invokeExt().Init(c). - Theme endpoint:
GET /api/v1/cluster/themestreams a zip fromconf.ThemePath(); only reinstall whenapp.jsis missing and always use the header helpers inpkg/http/header. - Registration flow: send
rotate=trueonly for MySQL/MariaDB nodes without credentials, treat 401/403/404 as terminal, includeClientID+ClientSecretwhen renaming an existing node, and persist only newly generated secrets or DB settings. - Registry & DTOs: use the client-backed registry (
NewClientRegistryWithConfig)—the file-backed version is legacy—and treat migration as complete only after swapping callsites, building, and running focused API/CLI tests. Nodes are keyed by UUID v7 (/api/v1/cluster/nodes/{uuid}), the registry interface stays UUID-first (Get,FindByNodeUUID,FindByClientID,RotateSecret,DeleteAllByUUID), CLI lookups resolveuuid → ClientID → name, and DTOs normalizeDatabase.{Name,User,Driver,RotatedAt}while exposingClientSecretonly during creation/rotation.nodes rm --all-idscleans duplicate client rows, admin responses may includeAdvertiseUrl/Database, client/user sessions stay redacted, registry files live underconf.PortalConfigPath()/nodes/(mode 0600), andClientDatano longer storesNodeUUID. - Provisioner & DSN: database/user names use UUID-based HMACs (
<prefix>d<hmac11>,<prefix>u<hmac11>where the prefix defaults tocluster_but may be overridden via the portal-onlydatabase-provision-prefixflag);BuildDSNaccepts adriverbut falls back to MySQL format with a warning when unsupported. - If we add Postgres provisioning support, extend
BuildDSNandprovisioner.DatabaseDriverhandling, add validations, and returndriver=postgresconsistently in API/CLI. - Testing: exercise Portal endpoints with
httptest, guard extraction paths withpkg/fs.Unzipsize caps, and expect admin-only fields to disappear when authenticated as a client/user session.