* Lightbox: Add 360° photo and video sphere viewer support Wires Photo Sphere Viewer v5 into the lightbox for equirectangular media. Backend detects UsePanoramaViewer and IsPhotosphere markers alongside ProjectionType, and exposes Panorama + Projection in the viewer / search result DTOs so the frontend can dispatch on the projection type. The renderer (ThreeJS + PSV core + video plugin + equirectangular video adapter) is lazy-loaded into a dedicated webpack chunk so the base bundle is unchanged for users with no 360° media. PhotoPrism's existing lightbox controls drive the underlying HTMLVideoElement that the PSV adapter creates for 360° videos. Includes Vitest coverage for the sphere helper and the lightbox dispatch, plus a TestCafe smoke spec and three new ExifTool metadata fixtures. * Lightbox: Fix 360° controls leak, hide zoom button, add sidebar icon * Lightbox: Fix 360° touch panning and navigation arrows * Lightbox: Fix fast swipe switching photos on 360° slides Suppress PhotoSwipe swipe/drag navigation at its source via the dispatched pointer hook while a sphere slide is active, since the per-element gesture trap is outrun by a fast swipe whose pointer leaves the sphere container (touch-capable Windows). UI controls stay excluded so buttons and arrows still navigate. * Lightbox: Route only equirectangular media to the 360° viewer Routing was based on the loose photo_panorama flag for videos, which is also true for cubemap and merely-wide (aspect > 1.9) videos, so non-equirectangular media wrongly opened, distorted, in the sphere viewer. Route strictly on the equirectangular projection for both photos and videos. Surface the video file's projection in the viewer DTO via Photo.MediaProjection() (the primary search row is a poster JPEG with no projection). Add an MP4 metadata-detection test. * Lightbox: Open tagless 2:1 360° videos in the sphere viewer Many 360° videos carry no projection tag PhotoPrism can read, so strict equirectangular-projection routing left them in the flat player. Fall back to equirectangular's defining 2:1 frame for panorama-flagged videos without a projection, via is360Equirectangular() in common/sphere.js, while cubemap and ultrawide (~2.35:1) clips stay flat. * Tests: Make the 360° sphere video test assert instead of skipping |
||
|---|---|---|
| .. | ||
| legacy | ||
| migrate | ||
| query | ||
| schema | ||
| search | ||
| sortby | ||
| testdata/album | ||
| album.go | ||
| album_cache.go | ||
| album_cache_test.go | ||
| album_fixtures.go | ||
| album_fixtures_test.go | ||
| album_test.go | ||
| album_user.go | ||
| album_yaml.go | ||
| album_yaml_test.go | ||
| auth_client.go | ||
| auth_client_add.go | ||
| auth_client_add_test.go | ||
| auth_client_data.go | ||
| auth_client_fixtures.go | ||
| auth_client_fixtures_test.go | ||
| auth_client_test.go | ||
| auth_session.go | ||
| auth_session_cache.go | ||
| auth_session_cache_test.go | ||
| auth_session_client.go | ||
| auth_session_client_test.go | ||
| auth_session_data.go | ||
| auth_session_data_test.go | ||
| auth_session_delete.go | ||
| auth_session_delete_test.go | ||
| auth_session_fixtures.go | ||
| auth_session_fixtures_test.go | ||
| auth_session_jwt.go | ||
| auth_session_jwt_test.go | ||
| auth_session_login.go | ||
| auth_session_login_test.go | ||
| auth_session_report.go | ||
| auth_session_report_test.go | ||
| auth_session_test.go | ||
| auth_tokens.go | ||
| auth_tokens_test.go | ||
| auth_user.go | ||
| auth_user_add.go | ||
| auth_user_cli.go | ||
| auth_user_default.go | ||
| auth_user_details.go | ||
| auth_user_details_test.go | ||
| auth_user_fixtures.go | ||
| auth_user_fixtures_test.go | ||
| auth_user_legacy.go | ||
| auth_user_legacy_test.go | ||
| auth_user_report.go | ||
| auth_user_report_test.go | ||
| auth_user_settings.go | ||
| auth_user_settings_test.go | ||
| auth_user_share.go | ||
| auth_user_share_fixtures.go | ||
| auth_user_share_fixtures_test.go | ||
| auth_user_share_test.go | ||
| auth_user_test.go | ||
| camera.go | ||
| camera_cache.go | ||
| camera_fixtures.go | ||
| camera_fixtures_test.go | ||
| camera_makes.go | ||
| camera_models.go | ||
| camera_test.go | ||
| camera_types.go | ||
| camera_types_test.go | ||
| category.go | ||
| category_fixtures.go | ||
| category_test.go | ||
| cell.go | ||
| cell_fixtures.go | ||
| cell_fixtures_test.go | ||
| cell_test.go | ||
| clip.go | ||
| clip_test.go | ||
| country.go | ||
| country_cache.go | ||
| country_fixtures.go | ||
| country_test.go | ||
| db.go | ||
| db_conn.go | ||
| db_conn_test.go | ||
| deprecated.go | ||
| details.go | ||
| details_fixtures.go | ||
| details_fixtures_test.go | ||
| details_test.go | ||
| duplicate.go | ||
| duplicate_test.go | ||
| entity.go | ||
| entity_const.go | ||
| entity_count.go | ||
| entity_count_test.go | ||
| entity_counts.go | ||
| entity_counts_test.go | ||
| entity_errors.go | ||
| entity_fixtures.go | ||
| entity_init.go | ||
| entity_save.go | ||
| entity_save_test.go | ||
| entity_tables.go | ||
| entity_test.go | ||
| entity_time.go | ||
| entity_time_test.go | ||
| entity_update.go | ||
| entity_update_test.go | ||
| entity_values.go | ||
| entity_values_test.go | ||
| error.go | ||
| face.go | ||
| face_fixtures.go | ||
| face_fixtures_test.go | ||
| face_test.go | ||
| faces.go | ||
| faces_test.go | ||
| file.go | ||
| file_fixtures.go | ||
| file_fixtures_test.go | ||
| file_json.go | ||
| file_json_test.go | ||
| file_metadata.go | ||
| file_share.go | ||
| file_share_fixtures.go | ||
| file_share_fixtures_test.go | ||
| file_share_test.go | ||
| file_sync.go | ||
| file_sync_fixtures.go | ||
| file_sync_fixtures_test.go | ||
| file_sync_test.go | ||
| file_test.go | ||
| fixtures.go | ||
| folder.go | ||
| folder_album_slug.go | ||
| folder_album_slug_test.go | ||
| folder_fixtures.go | ||
| folder_test.go | ||
| keyword.go | ||
| keyword_cache.go | ||
| keyword_cache_test.go | ||
| keyword_fixtures.go | ||
| keyword_fixtures_test.go | ||
| keyword_test.go | ||
| label.go | ||
| label_cache.go | ||
| label_cache_test.go | ||
| label_fixtures.go | ||
| label_fixtures_test.go | ||
| label_search.go | ||
| label_search_test.go | ||
| label_slug.go | ||
| label_slug_test.go | ||
| label_test.go | ||
| lens.go | ||
| lens_cache.go | ||
| lens_fixtures.go | ||
| lens_fixtures_test.go | ||
| lens_models.go | ||
| lens_test.go | ||
| link.go | ||
| link_fixtures.go | ||
| link_test.go | ||
| marker.go | ||
| marker_fixtures.go | ||
| marker_fixtures_test.go | ||
| marker_json.go | ||
| marker_json_test.go | ||
| marker_test.go | ||
| markers.go | ||
| markers_test.go | ||
| mysql8_test.go | ||
| passcode.go | ||
| passcode_fixtures.go | ||
| passcode_fixtures_test.go | ||
| passcode_json.go | ||
| passcode_json_test.go | ||
| passcode_test.go | ||
| password.go | ||
| password_fixtures.go | ||
| password_fixtures_test.go | ||
| password_test.go | ||
| photo.go | ||
| photo_album.go | ||
| photo_album_fixtures.go | ||
| photo_album_fixtures_test.go | ||
| photo_album_test.go | ||
| photo_caption.go | ||
| photo_caption_test.go | ||
| photo_datetime.go | ||
| photo_datetime_test.go | ||
| photo_estimate.go | ||
| photo_estimate_test.go | ||
| photo_fixtures.go | ||
| photo_fixtures_test.go | ||
| photo_interface.go | ||
| photo_keyword.go | ||
| photo_keyword_fixtures.go | ||
| photo_keyword_test.go | ||
| photo_label.go | ||
| photo_label_test.go | ||
| photo_location.go | ||
| photo_location_test.go | ||
| photo_merge.go | ||
| photo_merge_test.go | ||
| photo_optimize.go | ||
| photo_optimize_test.go | ||
| photo_quality.go | ||
| photo_quality_test.go | ||
| photo_reset.go | ||
| photo_reset_test.go | ||
| photo_scope_test.go | ||
| photo_test.go | ||
| photo_title.go | ||
| photo_title_test.go | ||
| photo_user.go | ||
| photo_vision.go | ||
| photo_yaml.go | ||
| photo_yaml_test.go | ||
| photos.go | ||
| photos_test.go | ||
| place.go | ||
| place_fixtures.go | ||
| place_fixtures_test.go | ||
| place_test.go | ||
| reaction.go | ||
| reaction_fixtures.go | ||
| reaction_test.go | ||
| README.md | ||
| service.go | ||
| service_fixtures.go | ||
| service_test.go | ||
| src.go | ||
| src_test.go | ||
| string_keyvalue.go | ||
| string_map.go | ||
| string_map_test.go | ||
| subject.go | ||
| subject_fixtures.go | ||
| subject_fixtures_test.go | ||
| subject_names.go | ||
| subject_person.go | ||
| subject_person_test.go | ||
| subject_test.go | ||
| subjects.go | ||
| subjects_test.go | ||
PhotoPrism — Database Entities
Last Updated: June 1, 2026
Overview
internal/entity holds the GORM models (Photo, File, Album, Label, Face, User, Client, Session, Service, Marker, …), their query and create/update helpers, the test fixtures (*_fixtures.go), and the migration helpers under migrate/. Models map to the database via GORM v1 (github.com/jinzhu/gorm) and are shared by the API, workers, and CLI.
Timestamps
Created and updated timestamps are stored as SQL DATETIME without fractional seconds (DATETIME_PRECISION = 0). To keep in-memory and persisted values in sync, the package sets GORM's timestamp source to second precision in db.go:
gorm.NowFunc = Now // entity.Now() == UTC().Truncate(time.Second)
Time helpers in entity_time.go:
UTC()— current time in UTC, full sub-second precision. Use for elapsed-time measurements, not for values that get persisted.Now()— UTC truncated to whole seconds. This is what GORM writes tocreated_at/updated_at.TimeStamp()— pointer toNow(), for nullable*time.Timecolumns.Time(s)— parses an RFC 3339 string to a second-precision UTC time, ornil.
Implications:
- Do not rely on sub-second ordering of persisted timestamps. Two rows created and updated within the same wall-clock second compare equal, so
created_at/updated_atcannot disambiguate them. There is no monotonic auto-increment ID on UID-keyed models (e.g.Client), so there is no reliable intra-second tiebreaker — give rows distinct times when ordering must be deterministic. - Because both SQLite and MariaDB now receive second-precision values, timestamp behavior is identical across drivers. A timestamp assertion that passes on SQLite will pass on MariaDB.
When a test needs to prove a write advanced a timestamp, prefer one of:
-
Seed the starting value clearly in the past (e.g.
Now().Add(-time.Hour)) and assert the new value is greater. This stays meaningful and distinguishes a real bump from a no-op. -
Compare with
Time.Sub()and assert the difference falls in a sane range, rather than a strictBefore/After. A same-second save legitimately yields a zero delta:elapsed := after.Sub(before) assert.GreaterOrEqual(t, elapsed, time.Duration(0)) assert.Less(t, elapsed, time.Minute)
Testing
Tests default to SQLite. To exercise the models against MariaDB (which is stricter and is the production database for some subsystems such as the cluster registry):
mysql < scripts/sql/reset-acceptance.sql
PHOTOPRISM_TEST_DRIVER="mysql" \
PHOTOPRISM_TEST_DSN="root:photoprism@tcp(mariadb:4001)/acceptance?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true" \
go test ./internal/entity/... -count=1 -tags="slow,develop"
MariaDB strict mode rejects inserts that SQLite quietly accepts, so a test that only ran on SQLite can fail here:
- Primary keys must be set. An empty PK (
""UID, zero ID) triggersError 1364: Field '<col>' doesn't have a default value. Use a valid ID/UID, not a placeholder like"1234". - Values must fit the column. Oversized strings give
Error 1406: Data too long; out-of-range integers giveError 1264: Out of range value(e.g.photo_idisINT UNSIGNED, max 4294967295). - UID format (see
pkg/rnd/uid.go): a one-byte prefix + 6 base36 time chars + 9 base36 random, 16 chars total (p…photo,a…album,c…client,u…user,l…label). Reuse existing fixtures for foreign-key safety; use a throwaway but in-range value only where a real reference would overwrite seeded data (e.g. a syntheticphoto_idso a Details row does not attach to a real photo). - Fixtures live in
*_fixtures.go, but some join rows are created indirectly from a parent fixture's embedded slice (e.g. aphotos_labelsrow from aPhotofixture'sLabels). Verify a combination is free against the seeded database, not just the fixtures file. List-style global queries (WHERE … <> ''with no per-test scope) are not isolated on the sharedacceptancedatabase: rows from other tests in the same run leak in, so alen(list) == Nassertion that holds on a per-test SQLite file can fail on MariaDB.
Collation & Emoji
MariaDB's utf8mb4_unicode_ci assigns most emoji the same collation weight, so an SQL =, <>, or LIKE on a utf8mb4 column treats distinct emoji as equal (e.g. test/🪞 matches test/🎃). SQLite compares text byte-exact, so this only reproduces on MariaDB.
utf8mb4columns that collapse:albums.album_title, display/name text (*_name,*_title).VARBINARYcolumns that stay byte-exact:albums.album_slug,albums.album_filter,albums.album_path,photos.photo_path, and every*_uid. Autf8mb4column compared against aVARBINARYcolumn is byte-exact (the binary operand wins).
The durable fix for an identity/path column is to make it VARBINARY — album_path is VARBINARY(1024) so it matches photos.photo_path and album_path = ? lookups are byte-exact at the database. Where a utf8mb4 column must stay, keep the SQL but re-verify the match byte-exact in Go before accepting it (see FindFolderAlbum / findFolderAlbumByPath, whose Go re-check is retained as defense-in-depth even now that album_path is VARBINARY). For self-join SQL where a Go re-check is awkward, HEX(col) = HEX(col) compares byte-exact on both MariaDB and SQLite. Legacy folder slugs drop emoji entirely (slug.Make("ins/🪞") == "ins") and long paths truncate to ClipSlug runes, so distinct folders can still collide on album_slug; folder albums are therefore deduplicated by album_filter (the byte-exact serialized path), not by slug (see query.RemoveDuplicateMoments).
VARBINARY Index Prefix Limit
InnoDB caps an index key prefix at 767 bytes on the COMPACT/REDUNDANT row formats, and only allows up to 3072 bytes on DYNAMIC/COMPRESSED. On a VARBINARY column the prefix is counted in bytes (on utf8mb4 it is counted in characters, i.e. up to 4 bytes each), so converting a long text column to VARBINARY can push an existing prefix index over the limit on older or non-DYNAMIC installs. Keep prefix indexes on long VARBINARY path/filter columns at ≤ 767 bytes; the project convention is 512 (albums.album_filter(512), albums.album_path(512)). A prefix index only narrows candidate rows — the full-column comparison stays exact — so a shorter prefix costs nothing for correctness.