Context
This document specifies how the Photo widget in the dashboard fetches from a photo provider (« photo provider ») rather than from a manually entered list of URLs, and defines the contract that any provider — internal service or external integration — must implement. Immich is the first provider.
The Photo widget (box.type = 'photo') already exists:
- rendering:
front/src/components/boxs/photo/PhotoBox.jsx(slideshow, forward/back navigation, indicators, image memory cache, preloading the next image) andEditPhotoBox.jsx(list of URLs + captions, cropping, interval, displaying captions); - model:
DASHBOARD_BOX_TYPE.PHOTO = 'photo'(server/utils/constants.js), configuration validated by the Joi schema ofserver/models/dashboard.js(photos: array of{ url, caption }, max 100,photo_fit,photo_slideshow_interval0–3600,photo_show_caption); - image retrieval:
GET /api/v1/dashboard/photo/proxy?url=→server/lib/dashboard/dashboard.getPhoto.js. The server downloads the image (so a local NAS remains visible remotely via Gladys Plus), re-encodes it in JPEG 800×400, quality 80 viaresizeImageBuffer, and returns the string"image/jpeg;base64,…"that the front consumes insrc={data:${image}}.
What’s blocking today. Feeding the widget from an Immich server is impossible without dedicated server code, for three cumulative reasons:
- Authentication. Immich requires an
x-api-keyheader on every request. The current proxy does a nakedGET, without a header: it cannot structurally talk to Immich. - Network. The current proxy intentionally blocks the local loop and link-local (SSRF protection, the URL coming from a free field). However, a self-hosted Immich is very often reachable at
http://localhost:2283,http://immich-server:2283(Docker network) or on a private IP: the « manual URL » path is the wrong tool for an address configured once by an administrator. - Dynamism. A list of URLs is static. An Immich album grows, and the « memories from that day » change every day: the source must be resolved into a list of photos at runtime, not at configuration.
Guiding Principle: The Core Knows No Provider by Name
The precedent is the weather widget (docs/specs/external-integrations.md §B.18): weather.get enumerates the stateManager and retains any service exposing weather.get(options), the widget optionally pins a provider (GET /api/v1/weather/provider, then ?service=), and a pivot format standardized by the core isolates the UI from each provider’s payloads. No hard-coded getService('openweather').
This specification exactly transposes this model to photos:
- the core exposes a
photo.*capability (gladys.photo) that enumerates services exposingphoto.getSources(...)/photo.getPhotos(...)/photo.getImage(...); - Immich is one provider among others, implemented in v1 as an internal service (
server/services/immich), exactly likeopenweatheris for the weather; - an external integration of
type: "photo"(Google Photos, PhotoPrism, Synology Photos, Nextcloud Photos…) will be able to implement the same contract without touching the core or the widget (phase 2, §F).
The cost is the same as for the weather: a generic core lib + a service. The benefit is that the 2nd, 3rd, 10th photo source will cost the core nothing.
Assumed Divergence with Weather: No Automatic Mode
Weather has a single « correct » answer (the weather here): the core can therefore try providers in order and take the first one that responds. Photos do not have this property: « my Immich’s Vacations 2019 album » has no equivalent with another provider. Therefore:
- the widget in provider mode always pins a service (
photo_provider) and a source (photo_source_type+photo_source_id); - there is no automatic mode, nor silent fallback: if the pinned provider is absent, stopped, or not configured, the widget displays an explicit state (§C.4) rather than someone else’s photos.
Scope
In Scope (v1)
- Generic « photo provider » contract + core lib
gladys.photo+ REST routes (§B). - Internal service Immich: configuration page (URL + API key + connection test), album listing, album/memories resolution, authenticated image proxy (§D).
- Photo widget: source mode selection, provider and source selection, order, ceiling, automatic captions (§C).
- Full backward compatibility of the « manual URLs » mode: no existing widget is modified, no migration.
A. Widget Configuration (Data Model)
A widget’s configuration lives in the JSON of the boxes in t_dashboard: no database migration. Only the Joi schema of server/models/dashboard.js is extended.
| Field | Type | Default | Role |
|---|---|---|---|
photo_source_mode |
'manual' | 'provider' |
'manual' |
Source mode. Absent ⇒ 'manual': this ensures backward compatibility of already registered widgets. |
photo_provider |
string (service name) | — | Pinned provider, e.g., immich. Required if photo_source_mode === 'provider'. |
photo_source_type |
'album' | 'memories' |
— | Source type with this provider. Free value on the contract side (§B.2), validated by the provider, not by the core. |
photo_source_id |
string ≤ 128 | '' |
Source identifier (Immich album UUID). Empty for a source without an identifier (memories). |
photo_order |
'recent_first' | 'oldest_first' | 'random' |
'recent_first' |
Display order (§E.2). |
photo_max |
integer 1–100 | 50 |
Ceiling of photos loaded from the source (§E.3). Aligned with the .max(100) already applied to photos. |
photo_caption_mode |
'auto' | 'none' |
'auto' |
In provider mode, caption generated from metadata (§E.4) or no caption. |
photos, photo_fit, photo_slideshow_interval, photo_show_caption, name |
unchanged | — | photos is only read in manual mode; the others apply to both modes. |
Additions to the Joi schema (server/models/dashboard.js):
photo_source_mode: Joi.string().valid('manual', 'provider'),
photo_provider: Joi.string().allow('').max(64),
photo_source_type: Joi.string().allow('').max(32),
photo_source_id: Joi.string().allow('').max(128),
photo_order: Joi.string().valid('recent_first', 'oldest_first', 'random'),
photo_max: Joi.number().integer().min(1).max(100),
photo_caption_mode: Joi.string().valid('auto', 'none'),
The schema remains permissive on photo_source_type (bounded string, not a valid()): adding a favorites source with a provider should not require a core modification, exactly like the type of the external integration manifest is not enumerated by the widget.
B. « Photo Provider » Contract (Core)
New lib server/lib/photo/, mounted in server/lib/index.js under gladys.photo, on the model of server/lib/weather/.
server/lib/photo/
index.js // Photo(service) + prototypes
photo.getProviders.js // duck-typed enumeration
photo.getSources.js // selectable sources of a provider
photo.getPhotos.js // source resolution -> normalized list
photo.getImage.js // bytes of a photo -> data URI, with cache
photo.normalize.js // normalizeSources / normalizePhotos
constants.js // identifier regexes, ceilings, cache TTL
B.1 Enumeration (duck typing, like weather.getProviders)
function getProviders() {
const serviceNames = this.service.stateManager.getAllKeys('service');
return serviceNames
.filter((serviceName) => {
const service = this.service.getService(serviceName);
return service && service.photo && typeof service.photo.getSources === 'function';
})
.sort();
}
A service is a provider if it exposes the three functions photo.getSources, photo.getPhotos, photo.getImage. getSources acts as a probe (an incomplete provider is a provider bug, not a case to be handled case by case in the core); the core checks the other two at the time of the call and raises NotFoundError if they are missing.
B.2 Pivot format of a source
{
"type": "album",
"id": "0d5f4c2e-…-uuid",
"label": "Vacances 2019",
"count": 248
}
| Field | Required | Normalization applied by the core |
|---|---|---|
type |
yes | ^[a-z][a-z0-9-]{0,31}$, otherwise the source is discarded |
id |
yes (can be "") |
^[A-Za-z0-9._:-]{0,128}$, otherwise discarded. "" = unique source of its type (memories) |
label |
yes | string bounded to 100 characters, truncated |
count |
no | finite integer ≥ 0, otherwise removed |
List bounded to 200 sources; beyond that, truncated (a user does not choose from a menu of 2000 albums — §E.5 handles search).
B.3 Pivot format of a photo
{
"id": "3f0a…-uuid",
"caption": "Rome — 12 août 2019",
"taken_at": "2019-08-12T14:03:11.000Z"
}
| Field | Required | Normalization |
|---|---|---|
id |
yes | ^[A-Za-z0-9._:-]{1,128}$, otherwise the photo is discarded. This is the opaque token that the widget will return to photo.getImage — the core never interprets it |
caption |
no | string bounded to 200 characters, truncated; empty ⇒ removed |
taken_at |
no | valid ISO date, otherwise removed |
No URL appears in the pivot, by design: the browser must never join the provider directly (no IP leak, no API key exposed, no remote access Gladys Plus break). Sorting and the ceiling are applied by the core after normalization (§E.2, §E.3), so that all providers behave the same.
B.4 Format of an image
photo.getImage returns the string "image/jpeg;base64,…" — exactly the format already produced by dashboard.getPhoto and consumed by PhotoBox/EditPhotoBox in data:${image}. The provider, on the other hand, returns a Buffer: it’s the core that validates and re-encodes (§B.6), so that validation never depends on the provider.
B.5 REST Routes
Added in server/api/routes.js via a photo.controller.js (the model is weather.controller.js), all authenticated: true without admin: any user configures their dashboard, as for GET /api/v1/weather/provider. The payload contains nothing operational (no server URL, no key).
| Route | Parameters | Response |
|---|---|---|
GET /api/v1/photo/provider |
— | [{ "service_name": "immich", "label": "Immich" }] — same shape as weather providers (label = display name of the manifest for external integration, null for an internal service, the front i18n taking over) |
GET /api/v1/photo/source |
service (required) |
[{ type, id, label, count }] — §B.2 |
GET /api/v1/photo/list |
service, source_type, source_id, order, limit |
{ "photos": [{ id, caption, taken_at }] } — §B.3 |
GET /api/v1/photo/image |
service, photo_id |
"image/jpeg;base64,…" (text/plain, like the existing proxy) |
GET /api/v1/dashboard/photo/proxy remains unchanged: this is the path of the manual mode, with its SSRF protection, and it is not concerned by these routes.
Errors: standard Gladys format (errorMiddleware). Unknown or non-provider service → 404 NOT_FOUND. Unconfigured provider → ServiceNotConfiguredError (the front displays the call to action « configure Immich »). Third-party failure → 400 with ERROR_MESSAGES.REQUEST_TO_THIRD_PARTY_FAILED, the same code that the weather widget already knows how to present.
B.6 What the core never trusts
Like normalizeWeather for weather, everything that comes from a provider is normalized and bounded before entering the core:
- bounded lists (200 sources, 100 photos), whitelisted fields (any unknown field is removed), truncated strings, validated dates, identifiers filtered by regex;
- image validated on the decoded bytes: only JPEG / PNG / WebP / AVIF / GIF magic numbers (no trust in the third-party’s
Content-Type), size ≤ 25 MB (aligned withMAX_SOURCE_IMAGE_BYTES), then systematic re-encoding byresizeImageBufferto 800×400 JPEG q80. A provider cannot therefore serve an SVG, an HTML, or a 200 MB file through the Gladys origin; photo_idre-checked by the core before any provider call: an identifier outside the regex returns404without a single byte being sent to the provider.
B.7 Caches (bounded, in memory)
| Cache | Key | TTL | Max Size |
|---|---|---|---|
| Sources | service |
5 min | 1 entry per provider |
| Photo list | service + source_type + source_id + order + limit |
5 min | 20 entries (LRU) |
| Image | service + photo_id |
10 min | 60 entries (LRU) — same order of magnitude as the weather image cache (10 min) |
After re-encoding, an image weighs ~30–60 KB: 60 entries ≈ 3 MB, acceptable on a Raspberry Pi. The image cache of the front (imageCache of PhotoBox) is also bounded to 60 LRU entries as part of this work: today it grows without limit, which worked with 100 manual URLs but deserves a limit as soon as an album is periodically refreshed.
The random mode is excluded from the list cache or, more simply, drawn on the server side with a seed derived from the cache window: two consecutive loads less than 5 minutes apart therefore return the same order — this is intentional, otherwise the slideshow’s forward/back navigation would jump from one photo to another without coherence.
C. Front
C.1 Editing the widget (EditPhotoBox.jsx)
A first select Photo Source: Manual URLs (default) / From an integration.
- Manual URLs: the current screen, identical.
- From an integration:
- select Provider ←
GET /api/v1/photo/provider. If the list is empty: help block « No photo integration is installed » with a link to the integrations catalog. - select Source ←
GET /api/v1/photo/source?service=…, grouped bytype: *Albums*, *Souvenirs*), labellabel+countwhen present. Writephoto_source_type**and**photo_source_id` in a single action. - Select Order (recent first / oldest first / random), Maximum number of photos field (1–100, default 50), Automatic captions switch.
- Preview : the first 3 resolved photos, loaded via
GET /api/v1/photo/image— same role as thePhotoPreviewin manual mode (check before saving), without duplicating its debounce logic since there is no character-by-character input.
- select Provider ←
Common options (cropping, interval, caption display, widget name) remain displayed in both modes.
C.2 Execution (PhotoBox.jsx)
PhotoBox gains a resolution step before its slideshow; everything that follows (current index, transitions, buttons, indicators, preloading, cache) is reused as is.
manualmode:photoscomes from the configuration, images via/api/v1/dashboard/photo/proxy?url=— unchanged.providermode: on mount,GET /api/v1/photo/list→ list of{ id, caption, taken_at }in state; each image viaGET /api/v1/photo/image?service=…&photo_id=…, the cache key being the URL of the request. The preloading of the next image works identically.
The component thus works on a resolved list common to both modes; this is the only real internal refactor, and it simplifies getDerivedStateFromProps (which today bounds the index based on props only).
C.3 Refresh
- On mount of the widget, and with each change in the source configuration.
- Periodically, every 60 minutes (
PROVIDER_LIST_REFRESH_MS) : sufficient for an album that is enriched, and this catches the midnight transition of memories in less than an hour. - On return to index 0 of the slideshow if the list is more than 60 min old: a dashboard left on for a wall remains up to date without an additional clock.
- A refresh that returns a shorter list than the current index brings the index within bounds (existing rule); an identical list does not trigger any image reload, the cache doing its job.
C.4 Display States
| Situation | Render |
|---|---|
| Empty source (empty album, no memories today) | Explicit empty state, not an error: « No photos in this source today. » (dedicated i18n key, distinct from emptyPhotos) |
| Unconfigured provider | Message + link to the integration configuration page |
| Missing / stopped / unreachable provider | Error message with the provider name pinned — never fallback to another provider |
| Failure of an isolated image | Current behavior: error icon on this photo, the slideshow continues |
F. Phase 2 — External integrations of type: "photo" (design, not implemented)
Direct transposition of docs/specs/external-integrations.md §B.18 (weather) and §B.15 (communication). Nothing specified in §A–E changes; the implementation of this phase will update external-integrations.md in the same diff, as its rule requires.
- Manifest :
type: "photo". Installation screen with a dedicated information line (« this integration can provide the photos displayed on your dashboards »). - Proxy service : exposes
photo.getSources/photo.getPhotos/photo.getImage, relayed via WebSocket:
| Command | Payload | Ack (command-result) |
Timeout |
|---|---|---|---|
external-integration.photo.get-sources |
{ message_id } |
data.sources |
15 s |
external-integration.photo.get-photos |
{ message_id, options: { source_type, source_id, limit } } |
data.photos |
15 s |
external-integration.photo.get-image |
{ message_id, photo_id } |
data.image (raw base64, no data-URI prefix) |
15 s |
The 15 s timeout is the same already accepted for camera.get-image and weather.get (third-party API call). The order and limit remain applied by the core: the integration receives limit as an indication (not to transfer 5000 entries), the core re-truncates after normalization.
- No « device » surface : like weather and communication, a photo integration has no Devices screen, no discovery, no states — only Configuration / Supervision / Logs.
- Nothing new to validate :
normalizeSources/normalizePhotos/ the image validation of §B.6 are written to be applied to all providers from v1 — the internal Immich service is treated with the same distrust as a third-party integration, ensuring that phase 2 adds no surface of trust.