The public catalog exposes only currently policy-eligible media through the same
Gateway origin as media delivery. nginx proxies its explicit GET routes to the
numeric loopback process. Every catalog response grants
Access-Control-Allow-Origin: *: browsers use ordinary GET without cookies,
Authorization or other custom request headers. Credentialed CORS and OPTIONS are
unsupported. Current lifecycle and publication policy authorize every result.
Call the public Gateway origin from the application or browser. The following uses synthetic IDs and logical selectors:
# Browse a small page; URL-encode each subsequent opaque cursor unchanged.
curl --fail --get 'https://media.example.com/catalog/collections' \
--data-urlencode 'limit=25'
Select one returned collection; descendants are separate collections. Use the query
key collection (the JSON response calls the field collection_path):
curl --fail --get 'https://media.example.com/catalog/assets' \
--data-urlencode 'root=photos' --data-urlencode 'collection=2026/example-trip/public' \
--data-urlencode 'limit=25'
curl --fail 'https://media.example.com/catalog/assets/12345678-1234-4234-8234-123456789abc'
Consume each page, then repeat the same operation/selectors with
--data-urlencode "cursor=$NEXT_CURSOR" until next_cursor is null. A short or
empty page can still have continuation. Restart browsing after gateway restart
invalidates a cursor; merge collections by (root, collection_path) and avoid
assuming a provider snapshot. Never print real catalog responses into public logs.
For an interactive collection browser, optionally add logical search and filters:
curl --fail --get 'https://media.example.com/catalog/collections' \
--data-urlencode 'q=2026 trip' --data-urlencode 'root=photos' \
--data-urlencode 'media=image' --data-urlencode 'limit=25'
Use each collection’s representative_preview_path with the same public origin
for a thumbnail. It may change on later pages or return 404 after revocation.
To fill a first screen, consumers may automatically follow a small bounded number
of continuations, merging identities. A short page does not establish exhaustion.
Select an asset from the /catalog/assets response above. For example, these
fields from a synthetic asset show the default privacy mode (other fields omitted):
{
"id": "12345678-1234-4234-8234-123456789abc",
"preview_path": "/media/12345678-1234-4234-8234-123456789abc/preview",
"original_path": null
}
Copy the returned preview_path and use the same public origin:
# Synthetic path: replace with the selected asset's returned preview_path.
preview_path='/media/12345678-1234-4234-8234-123456789abc/preview'
curl --fail --output /dev/null "https://media.example.com$preview_path"
After the operator has installed nginx and configured public HTTPS, prepend the configured public gateway origin to the same path:
# Synthetic origin: replace with your configured public origin, without a trailing slash.
gateway_origin='https://media.example.com'
# Requests https://media.example.com/media/12345678-1234-4234-8234-123456789abc/preview
curl --fail --output /dev/null "$gateway_origin$preview_path"
The consumer constructs this URL; catalog responses contain paths, not full URLs. Setting an origin does not install nginx, configure DNS or enable HTTPS.
When privacy.expose_source_metadata is omitted or false, original_path is
null and public original GET/HEAD remains 404. Do not construct an original URL
from the ID to bypass that setting. When explicitly true, use the returned
non-null original_path in exactly the same way:
# Only with source metadata enabled; copy the selected asset's returned original_path.
original_path='/media/12345678-1234-4234-8234-123456789abc/original'
curl --fail --output /dev/null "$gateway_origin$original_path"
Originals preserve exact source bytes, including embedded EXIF/GPS or container metadata. See source-metadata privacy and delivery semantics for the detailed contracts.
A returned path is not proof that its representation is available. Each enabled delivery rechecks current lifecycle and publication policy; storing a URL never grants authority. Handle 404 revocation/missing representations and sanitized 502 failures at delivery. Your application owns selection persistence, captions and presentation.
Only GET is accepted:
GET /catalog/collections?q=<query>&root=<logical-root>&media=<image|video>&limit=<n>&cursor=<opaque>
GET /catalog/assets?root=<logical-root>&collection=<relative-path>&media=<image|video>&q=<query>&limit=<n>&cursor=<opaque>
GET /catalog/assets/<UUIDv4>
Detail accepts no query, including an empty ?. Routes are exact, with no path cleaning or redirects. Unknown/duplicate query keys, explicit empty values, invalid encoding and malformed selectors receive the fixed 404 denial before provider access.
Asset browsing requires an exact configured logical root and a canonical relative POSIX collection: nonempty valid UTF-8, no leading/trailing slash, empty/dot/dot-dot components, backslashes or control characters. Normal query decoding applies once; selectors are never cleaned or Unicode-normalized into validity. Consumers never supply an absolute provider path.
Every active candidate passes publication.Evaluate. Asset browsing additionally requires exact root and parent-collection equality: descendants are separate collections. A valid known-root selector with no eligible media returns 200 with an empty page, without revealing whether a private directory exists. A stored asset reference grants no authority; detail independently rechecks current lifecycle and publication policy.
Asset lists accept optional media=image or media=video; omission preserves
mixed image/video browsing. The selector narrows the provider type predicate, but
every candidate still passes current lifecycle, publication policy, exact logical
root and parent collection, requested media, optional filename search and safe
projection. It cannot make a wrong-rule or otherwise private asset public.
Empty, unknown or duplicate media values receive fixed 404 before provider I/O. Detail accepts no query. Keep the same media selector on continuations; changing, adding or removing it invalidates the cursor. Response fields, source-metadata privacy and independently authorized preview/original delivery are unchanged.
Add optional q to the asset-list route to search the entire selected exact
collection through pagination:
curl --fail --get 'https://media.example.com/catalog/assets' \
--data-urlencode 'root=photos' --data-urlencode 'collection=2026/example-trip/public' \
--data-urlencode 'media=image' --data-urlencode 'q=215006 DxO' \
--data-urlencode 'limit=25'
The Gateway splits the decoded query on Unicode whitespace and lowercases terms
and eligible basenames using Go Unicode case conversion. Every term must occur
as a substring of the basename, in any order: 215006 DxO matches
20231026_215006_DxO.jpg. Responses preserve the original filename. Matching does
not search parent paths or provider display names and adds no ranking, stemming
or fuzzy matching.
q is accepted on asset lists and collection discovery. Explicit empty or whitespace-only values,
duplicate keys, malformed encoding, invalid UTF-8, control characters and queries
longer than 256 decoded UTF-8 bytes receive fixed 404 before provider access.
Omitting q preserves ordinary browsing behavior.
Search filters the existing candidate stream after lifecycle, publication policy,
exact root/collection and optional media checks; it adds no provider filename predicate. The same
eight-call and 30-second bounds apply. Nonmatches consume scan work, so a page can
be empty with a non-null continuation. Keep the exact decoded q value on every
continuation request, even its case and spacing, and continue until next_cursor
is null. Stable eligible streams eventually expose matching assets; provider
pagination is not a snapshot, so library changes can cause repeats or skips.
Immich v3.2.2’s structured
originalFileName pattern filter
was rechecked on 2026-10-03. Its stored original filename is not guaranteed to
equal the path basename that Gateway searches: the
upload service
stores it separately, and
storage templates
can rename the source path. The predicate could therefore exclude a valid Gateway
match. Final Gateway matching can reject provider overmatches but cannot recover
omitted candidates, so filename narrowing remains disabled. This preserves all
existing Unicode, multi-term and exact decoded-query semantics within the same bounds.
| Boundary | Limit |
|---|---|
| Default consumer page | 25 |
| Maximum consumer page | 100 |
| Raw query | 8 KiB |
| Encoded gateway cursor | 2 KiB |
| Decoded collection selector | 2 KiB UTF-8 |
| Decoded search query | 256 UTF-8 bytes |
| Provider calls per request | 8 |
| Overall catalog work | 30 seconds |
| Encoded consumer JSON | 512 KiB |
limit accepts decimal digits only, with value 1–100. Each provider page requests at most the remaining consumer output slots. All received candidates are consumed, so filling cannot skip an unconsumed tail. Sparse pages may exhaust the successful-work budget with fewer than limit results, including zero, and a continuation. Provider/auth/transport/malformed-metadata failures still fail the whole request; accumulated results are never returned as partial success. Disconnect cancels outstanding work.
Cursors are gateway-owned versioned HMAC-SHA256 tokens, authenticated using a random 32-byte process-start key. Startup fails if randomness is unavailable. There is no cursor file, configuration, database or session cache. The payload contains only version, operation, query/policy fingerprint, discovery-stream index and opaque provider continuation. No provider paths or credentials are included. MAC comparison is constant-time.
Wrong version/kind/fingerprint, invalid MAC, malformed or oversized tokens fail before provider I/O. Asset tokens bind the exact decoded (root, collection) selector, optional decoded q and media, and policy; collection tokens bind deterministic effective discovery streams and the exact decoded q, root and media filters. Preserve their case and spacing on continuations; changing or omitting a filter invalidates the cursor. Cursors never grant publication authority and are never logged. Restart invalidates existing tokens: consumers restart browsing. Consumers cannot directly submit provider cursors.
{
"collections": [
{
"root": "photos",
"collection_path": "2026/example-trip/public-images",
"representative_preview_path": "/media/12345678-1234-4234-8234-123456789abc/preview",
"representative_media_type": "image"
}
],
"next_cursor": null
}
Collection identity is exactly (root, collection_path), derived only from the successful publication policy result for an active eligible asset. next_cursor is always present as a string or null.
Every collection includes a normal preview path and image or video media type
from the same eligible candidate that discovered it. There is no additional detail,
preview or original fetch. These fields are presentation metadata, not identity or
delivery authorization. A later occurrence of the collection may nominate a
different candidate; each preview request independently reauthorizes and may 404.
The additions do not change the identity fields used by existing consumers.
Discovery combines applicable global and root-scoped rules by exact segment, unions media sets, and traverses streams sorted by logical root name then segment. Provider root-prefix and segment-shaped filters only narrow candidates. Immich’s case/accent-insensitive matching never replaces exact policy evaluation. Discovery uses withExif=false and decodes only lifecycle/path/media facts; unrelated dimensions, times or EXIF cannot fail discovery.
Collection discovery is at least once: identities are deduplicated within a response page, but may recur on later pages. Consumers must idempotently merge by (root, collection_path). Stable accessible candidate streams eventually expose their eligible collections. Immich offset pagination is not a snapshot; library mutations can also cause repeats/skips. There are no persistent seen sets or cursor sessions. Immich folder-view endpoints are not used: their unpaginated, timeline-specific behavior does not satisfy this contract.
All collection filters are optional and may be combined. Omitting them preserves the existing discovery stream and ordering; no global collection sorting is offered.
q searches exactly root + "/" + collection_path after authorization, never
provider absolute paths or asset filenames. The same Unicode whitespace,
lowercase and every-term substring rules and 256-byte decoded limit described
for filename search apply. Terms may occur in any order.root selects an exact logical name: 1–64 lowercase ASCII letters, digits or
hyphens with alphanumeric ends. Syntax is validated without looking up configured
roots. Unknown roots and configured roots with no currently eligible public
result both return successful empty pages with the same bounded continuation
behavior. The selector filters only authorized logical results over the same
discovery streams for every valid root. No configured-root enumeration or
special-case topology probe exists; asset-route root semantics are unchanged.media accepts only image or video. A collection is included only when a
candidate of that type passes current lifecycle and publication policy, and its
representative has that type. The provider type predicate narrows candidates
and incompatible rule streams are skipped; overmatches still fail Gateway checks.Malformed, empty, duplicate or unsupported filters receive the fixed 404 before provider I/O. Search/filter matches confined to private, wrong-media, trashed or offline assets yield ordinary empty pages. Raw values never enter routine logs.
Duplicates and nonmatches consume the same eight-call / 30-second work budget.
An empty filtered page may therefore carry next_cursor; only terminal stream
exhaustion yields null. This also applies to unknown roots. No request promises
exactly limit collections or hides a full-library crawl.
Collection q adds no provider path predicate. Immich v3.2.2 still offers
structured originalPath patterns, but absolute provider prefixes differ from
logical identity and its case/accent matching is not the Go Unicode matcher.
There is no proven complete translation for every logical multi-term query.
Existing trusted root/convention narrowing remains non-authoritative; the final
logical search runs in the Gateway, with bounded sparse-search continuations.
No image_count or video_count fields are returned. Immich v3.2.2
search statistics
count provider-filter matches. Its
path predicates
can overmatch case/accents and do not perform the Gateway’s canonical-path, exact
collection, lifecycle and publication evaluation. Neither raw totals nor the
consumed candidate page prove full eligible image/video totals. No exact,
policy-equivalent, non-N+1 aggregation was established, so counts are omitted
instead of adding scans, persistent state or approximate integers.
Asset lists return {"assets": [...], "next_cursor": null}; detail returns one asset directly. Every asset has exactly these fields (synthetic privacy-off defaults below):
{
"id": "12345678-1234-4234-8234-123456789abc",
"media_type": "image",
"root": "photos",
"collection_path": "2025/example-trip/post",
"filename": "DSC01234.JPG",
"width": 6000,
"height": 4000,
"duration_ms": null,
"file_created_at": null,
"local_date_time": null,
"latitude": null,
"longitude": null,
"preview_path": "/media/12345678-1234-4234-8234-123456789abc/preview",
"original_path": null
}
media_type is image or video. filename is the basename of the validated provider path, never an arbitrary provider display filename. Width, height and duration_ms are explicit nullable nonnegative safe JSON integers (maximum 9007199254740991). Duration is milliseconds, so a 23.8-second video has duration_ms: 23800. Zero is valid; null means unknown. When source metadata is enabled, both required time strings are validated as RFC3339 with optional fractional seconds and preserved; local wall time is not converted to another timezone.
An example video page with source metadata enabled (synthetic values) uses the same schema. A detail request returns the asset object directly, without the page envelope:
{
"assets": [{
"id": "12345678-1234-4234-8234-123456789abd",
"media_type": "video",
"root": "photos",
"collection_path": "2026/example-trip/public",
"filename": "clip.mp4",
"width": 1920,
"height": 1080,
"duration_ms": 23800,
"file_created_at": "2026-06-01T10:00:00Z",
"local_date_time": "2026-06-01T12:00:00Z",
"latitude": null,
"longitude": null,
"preview_path": "/media/12345678-1234-4234-8234-123456789abd/preview",
"original_path": "/media/12345678-1234-4234-8234-123456789abd/original"
}],
"next_cursor": null
}
The optional startup setting [privacy] expose_source_metadata = false controls
this fixed surface. It cannot be selected by HTTP callers.
| Field or behavior | Omitted / false | True |
|---|---|---|
file_created_at |
Present, null | Validated capture timestamp |
local_date_time |
Present, null | Validated local timestamp, unchanged timezone meaning |
latitude, longitude |
Both present, null | Validated nullable pair |
original_path |
Present, null | /media/<id>/original |
| Original image/video GET/HEAD, including Range | Fixed 404, zero provider original fetches | Existing exact-byte, range and streaming contract |
preview_path |
/media/<id>/preview |
Same |
Asset list/detail withExif |
false | true |
Collection discovery withExif |
false | false |
id, media_type, root, collection_path, filename |
Existing semantics | Same |
width, height, duration_ms |
Validated nullable integers | Same |
While off, hidden capture/local timestamps and EXIF are ignored rather than
validated, including malformed values within otherwise valid bounded JSON.
With true, timestamps retain RFC3339 validation and original precision. Coordinates
are both absent/null or a finite numeric pair: latitude [-90,90], longitude
[-180,180]. Zero and inclusive boundaries are valid. Partial pairs,
null/numeric mismatches, nonnumeric/non-finite and out-of-range values fail safely
with sanitized provider errors. Unrelated EXIF is discarded and never logged.
This is source-metadata privacy, not complete anonymity: filenames and collection names can contain descriptive information. Enabling it deliberately exposes approved sensitive fields publicly and enables exact source originals, whose bytes may contain arbitrary additional embedded metadata. No EXIF stripping, re-encoding or remuxing occurs. Absence of provider coordinates does not establish original-byte safety.
Preview/poster bytes and authorization are unchanged in both modes. Non-null capabilities advertise implemented representations, not existence guarantees or authorization grants; projection makes no representation probes. Every enabled delivery rechecks current lifecycle and policy. RAW/HEIC originals need not be browser-displayable. Startup configuration is immutable and restart invalidates cursor signing keys, so privacy mode is not caller-controlled cursor state.
Provider absolute/NAS paths, URLs, credentials, owner/library IDs, checksums, people/albums, visibility flags and raw EXIF/provider JSON never cross the consumer boundary. Full upstream JSON, including ignored EXIF, remains bounded to 1 MiB.
All responses use Cache-Control: no-store and X-Content-Type-Options: nosniff. JSON is buffered before success headers. Invalid/private/missing/unsupported/trashed/offline detail uses fixed 404 not found; provider/auth/transport/malformed-response/timeout failures use fixed 502 media unavailable. No provider bodies, paths, cursors, coordinates or raw query values enter routine logs.
Consumers own picker/tree UI, selected-reference persistence, gallery ordering/captions, derivatives and presentation of publicly available coordinates. Media Gateway owns current eligibility and safe metadata; it has no catalog database, media manager or web UI.