38 KiB
PhotoPrism® Repository Guidelines
Last Updated: November 23, 2025
Purpose
This file tells automated coding agents (and humans) where to find the single sources of truth for building, testing, and contributing to PhotoPrism. Learn more: https://agents.md/
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) - Face Detection & Embeddings Notes:
internal/ai/face/README.md - Vision Engine Guides:
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 & Usage)
- In the main repo,
specs/appears ignored because it is managed as a nested Git repository; change intospecs/before staging or committing spec 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, run its tools directly (e.g.,bash specs/scripts/lint-status.sh). - 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. - Regenerate NOTICE files with
make noticewhen dependencies change. Do not editNOTICEorfrontend/NOTICEmanually.
Style note: Document headings must use Title Case (capitalize words ≥4 letters in AP-style) across Markdown files to keep generated navigation and changelogs consistent.
CLI note: When writing CLI examples or scripts, place option flags before positional arguments unless the command requires a different order.
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/. - GORM field naming: When adding struct fields that include uppercase abbreviations (e.g.,
LabelNSFW), set an explicitgorm:"column:<name>"tag so column names stay consistent (label_nsfwinstead oflabel_n_s_f_w).
- Package boundaries: Code in
- 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 one of the following is true:
- File exists:
/.dockerenv - Project path equals (or is a direct child of):
/go/src/github.com/photoprism/photoprism
- File exists:
Examples
Bash:
if [ -f "/.dockerenv" ] || [ -d "/go/src/github.com/photoprism/photoprism/.git" ]; then
echo "container"
else
echo "host"
fi
Node.js:
const fs = require("fs");
const inContainer = fs.existsSync("/.dockerenv");
const inDevPath = fs.existsSync("/go/src/github.com/photoprism/photoprism/.git");
console.log(inContainer || inDevPath ? "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.
- 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).
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.
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.
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.
Safety & Data
- Never commit secrets, local configurations, or cache files. Use environment variables or a local
.env.- Ensure
.envand.localare ignored in.gitignoreand.dockerignore.
- Ensure
- Prefer using existing caches, workers, and batching strategies referenced in code and
Makefile. Consider memory/CPU impact; suggest benchmarks or profiling only when justified. - Do not run destructive commands against production data. Prefer ephemeral volumes and test fixtures when running acceptance tests.
If anything in this file conflicts with the
Makefileor the Developer Guide, theMakefileand the documentation win. When unsure, ask for clarification before proceeding.
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.
-
Examples assume a Linux/Unix shell. For Windows specifics, see the Developer Guide FAQ: https://docs.photoprism.app/developer-guide/faq/#can-your-development-environment-be-used-under-windows
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.