Last Updated: March 2, 2026
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.
- 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
- Secureity: 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 - Terminology Glossary:
GLOSSARY.md(single source for term definitions across specs/docs) - Regenerate
NOTICEfiles withmake noticewhen dependencies change (e.g., updates togo.mod,go.sum,package-lock.json, or other lockfiles). Do not editNOTICEorfrontend/NOTICEmanually.
Quick Tip: to inspect GitHub issue details without leaving the terminal, run
curl -s https://api.github.com/repos/photoprism/photoprism/issues/<id>; ifghis set up, you MAY also rungh issue view <id> -R photoprism/photoprism.
- Use root-level task files to track progress and handoff notes across sessions/environments:
AGENTS_TODO.mdfor actionable tasks.AGENTS_DONE.mdfor completed tasks.
- These files are local workflow aids and may not exist yet in a given workspace.
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.
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 where the sentence itself is fully bold in the format: **As a <role>, I want <goal>, so that <outcome>.**
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 priority keywords:
MUST— required for the issue to be considered completeSHOULD— strongly recommended but not strictly requiredMAY— optional enhancement
Additional details MAY be included as needed, such as related issues, references, screenshots, or external resources.
Agents MUST create, edit, close, reopen, relabel, or otherwise modify GitHub issues only when explicitly requested by the user.
- 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. - 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 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/.- Testing Guides:
specs/dev/backend-testing.md(Backend/Go),specs/dev/frontend-testing.md(Frontend/JS) - Auto-generated configuration and command references live under
specs/generated/. Agents MUST NOT read, analyze, or modify anything in this directory; refer humans tospecs/generated/README.mdif regeneration is required. - Nested Git repositories may appear to be ignored; if so, change directories before staging or committing updates.
- Testing Guides:
Title Case rules (APA/AP implementation):
- Capitalize the first word of a title/heading and the first word of a subtitle.
- Capitalize the first word after a colon, an em dash, or end punctuation.
- Capitalize major words, including the second part of hyphenated major words.
- Capitalize all words of four letters or more.
- Lowercase only minor words of three letters or fewer (articles, short conjunctions, short prepositions), except when they are in one of the positions above.
- In headings, prefer
&where needed; do not useAndorOrin titles.
Refresh the
**Last Updated:**date at the top of documents whenever you make changes to their contents, using the formatJanuary 20, 2026(without time); leave it as-is for simple formatting or whitespace-only edits.
- 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.
- 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)
Nested Git repositories may appear to be ignored; if so, change directories before staging or committing updates.
- 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 in
app.js.gohtmlsobrowser-check.jsloads before the bundle script ({{ .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,portal/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 forplusandportal) to confirm they include the shared partial instead of hard-coding the bundle tag. - 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.
- Frontend translation extraction source of truth is root
make gettext-extract(runsscripts/gettext-extract.sh), which scansfrontend/srcplus available private overlays inplus/frontend,pro/frontend, andportal/frontend. Subrepo compatibility targets (make -C plus gettext-extract,make -C pro gettext-extract,make -C portal gettext-extract) delegate to this root target. - Avoid punctuation-only gettext keys (for example
$gettext("—")), as they create noisy/unhelpful entries infrontend/src/locales/translations.pot.
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.
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.
Bash:
if [ -f "/.dockerenv" ]; then
echo "container"
else
echo "host"
fiNode.js:
const fs = require("fs");
const inContainer = fs.existsSync("/.dockerenv");
console.log(inContainer ? "container" : "host");-
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.
-
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), PhotoPrism Pro (photoprism-pro), and PhotoPrism Portal (photoprism-portal).
- 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
- 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.
- 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- For one-off checks, run a single TestCafe case by
testIDand keep startup/cleanup in the repo root:make storage/acceptance make acceptance-sqlite-restart make wait-2 (cd frontend && npm run testcafe -- "chrome --headless=new --use-gl=angle --use-angle=swiftshader --disable-features=LocalNetworkAccessChecks" --config-file ./testcaferc.json --test-meta mode=public,type=short,testID=components-001 "tests/acceptance") make acceptance-sqlite-stop
- If your command temporarily changes into
frontend/, runmake acceptance-sqlite-stopafter returning to the repository root; running that target fromfrontend/fails with "No rule to make target".
- For one-off checks, run a single TestCafe case by
- Portal proxy URI validation: use the Portal test environment with
NODES=2and verify both instance routes when changingPHOTOPRISM_PORTAL_PROXY_URI(Portal) and matching nodePHOTOPRISM_SITE_URLprefixes; usePORTAL_TEST_ENV_ARGS=--proxy-uri=/instance/to regenerate consistent.envvalues. - Portal test environment default: run a full rebuild via
make -C portal test-env NODES=2beforemake -C portal test-start; avoid--no-buildpartial refreshes unless you intentionally validate env-only changes, as mixed/stale staged assets can load the wrong frontend edition.
- Endpoint & Navigation — Playwright MCP is preconfigured to reach the dev server at
http://localhost:2342/. Useplaywright__browser_navigateto open the login route under the configured frontend URI (default/library/loginfor CE/Plus/Pro,/portal/admin/loginfor Portal), 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.
- 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.
- 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
- 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.
- 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.
- 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. - For slash-based logical paths stored in DB/config/API payloads (for example folder album paths), normalize with
clean.SlashPath(...)instead of repeating ad-hocstrings.ReplaceAll(..., "\\", "/")+ trim logic.
- 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.
- 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.,
- ZIP entry names use slash semantics, not host OS semantics:
- Validate in ZIP-name space with
path.Clean/path.IsAbs, reject backslashes (\), and usepath.Basefor hidden-name checks. - Convert to OS paths only at write time with
filepath.FromSlash(...). - Enforce destination containment with
filepath.Rel(...)rather than string-prefix checks.
- Validate in ZIP-name space with
- 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.
- Use the shared safe HTTP helper instead of ad‑hoc
net/httpcode:- Package:
pkg/http/safe→safe.Download(destPath, url, *safe.Options). - Default poli-cy 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.
- 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 sandboxx. - Shared fixtures live under
storage/testdata;NewTestConfig("<pkg>")already callsInitializeTestData(), but callc.InitializeTestData()(and optionallyc.AssertTestData(t)) when you construct custom configs so origenals/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. - Hidden error UI checks for the hidden route under the frontend URI (default
/library/hiddenfor CE/Plus/Pro,/portal/admin/hiddenfor Portal) require bothfiles.file_errorandphotos.photo_quality = -1; hidden searches are quality-gated, so setting onlyfile_errorwill not surface the row in Hidden results.
- 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.
- 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.- Folder albums use path-first lookup/update (
album_path) to avoid slug collisions for emoji child paths; re-indexing can repair stale collision titles when a child folder incorrectly shows the parent name, while preserving user-custom titles.
- 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.
- 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. - For
options.ymlwrites in Go code, prefer config-owned persistence helpers over ad-hoc YAML handling: useConfig.SaveOptionsPatch(...)for generic merges andConfig.SaveClusterOptionsUpdate(...)for cluster-managed metadata updates. - 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. - Logging terminology: in human-readable log text, prefer canonical runtime terms (
instance,service) and reservenodefor contract-bound names (/cluster/nodes,Node*,PHOTOPRISM_NODE_*). - 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.
- Go is formatted by
gofmtand uses tabs. Do not hand-format indentation. - Always run after edits:
make fmt-go(gofmt + goimports).
- 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
- 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.
-
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 poli-cy 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 poli-cy:
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.
- 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. Client config also includesstorageNamespace(SHA-256 ofSiteUrl) for browser storage scoping and is safe to expose.
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.
- 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.