# Widget Photo - sources issues d'intégrations (Immich)

**URL:** <https://community.gladysassistant.com/t/widget-photo-sources-issues-dintegrations-immich/10655>\
**Category:** Demande de fonctionnalités\
**Created:** [Août 18, 2026, 7:49 UTC](https://community.gladysassistant.com/t/widget-photo-sources-issues-dintegrations-immich/10655 "2026-08-18T07:49:08Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![cicoub13](https://gladys-assistant-community.b-cdn.net/user_avatar/community.gladysassistant.com/cicoub13/32/4482_2.png) [@cicoub13](https://community.gladysassistant.com/u/cicoub13)\
**Post date:** [Août 18, 2026, 7:49 UTC](https://community.gladysassistant.com/t/widget-photo-sources-issues-dintegrations-immich/10655/1 "2026-08-18T07:49:08Z")

</div>

## Contexte

Ce document spécifie comment le widget Photo du dashboard s’alimente auprès d’un **fournisseur de photos** (« photo provider ») plutôt qu’auprès d’une liste d’URLs saisies à la main, et définit le contrat que tout fournisseur — service interne comme intégration externe — doit implémenter. Immich est le premier fournisseur.

Le widget Photo (`box.type = 'photo'`) existe déjà :

- rendu : `front/src/components/boxs/photo/PhotoBox.jsx` (diaporama, navigation avant/arrière, indicateurs, cache mémoire des images, préchargement de l’image suivante) et `EditPhotoBox.jsx` (liste d’URLs + légendes, cadrage, intervalle, affichage des légendes) ;
- modèle : `DASHBOARD_BOX_TYPE.PHOTO = 'photo'` (`server/utils/constants.js`), configuration validée par le schéma Joi de `server/models/dashboard.js` (`photos` : tableau de `{ url, caption }`, **max 100** , `photo_fit`, `photo_slideshow_interval` 0–3600, `photo_show_caption`) ;
- récupération des images : `GET /api/v1/dashboard/photo/proxy?url=` → `server/lib/dashboard/dashboard.getPhoto.js`. Le serveur télécharge l’image (donc un NAS local reste visible à distance via Gladys Plus), la ré-encode en JPEG **800×400, qualité 80** via `resizeImageBuffer`, et renvoie la chaîne `"image/jpeg;base64,…"` que le front consomme en `src={`data:${image}`}`.

**Ce qui bloque aujourd’hui.** Alimenter le widget depuis un serveur Immich est impossible sans code serveur dédié, pour trois raisons cumulées :

1. **Authentification.** Immich exige un en-tête `x-api-key` sur chaque requête. Le proxy actuel fait un `GET` nu, sans en-tête : il ne peut structurellement pas parler à Immich.
2. **Réseau.** Le proxy actuel bloque volontairement la boucle locale et le link-local (protection SSRF, l’URL venant d’un champ libre). Or un Immich auto-hébergé est très souvent joignable en `http://localhost:2283`, `http://immich-server:2283` (réseau Docker) ou sur une IP privée : le chemin « URL manuelle » est le mauvais outil pour une adresse configurée **une fois** par un administrateur.
3. **Dynamisme.** Une liste d’URLs est figée. Un album Immich s’enrichit, et les souvenirs « ce jour-là » changent **tous les jours** : il faut résoudre la source en liste de photos à l’exécution, pas à la configuration.

## Principe directeur : le cœur ne connaît aucun fournisseur par son nom

Le précédent est le widget météo (`docs/specs/external-integrations.md` §B.18) : `weather.get` **énumère le stateManager** et retient tout service exposant `weather.get(options)`, le widget épingle éventuellement un fournisseur (`GET /api/v1/weather/provider`, puis `?service=`), et un **format pivot** normalisé par le cœur isole l’UI des payloads de chaque fournisseur. Aucun `getService('openweather')` en dur.

Cette spécification transpose exactement ce modèle aux photos :

- le cœur expose une capacité **`photo.*`** (`gladys.photo`) qui énumère les services exposant `photo.getSources(...)` / `photo.getPhotos(...)` / `photo.getImage(...)` ;
- **Immich est un fournisseur parmi d’autres** , implémenté en v1 comme service interne (`server/services/immich`), exactement comme `openweather` l’est pour la météo ;
- une intégration externe de `type: "photo"` (Google Photos, PhotoPrism, Synology Photos, Nextcloud Photos…) pourra implémenter le même contrat **sans toucher au cœur ni au widget** (phase 2, §F).

Le coût est le même que pour la météo : une lib cœur générique + un service. Le bénéfice est que la 2ᵉ, 3ᵉ, 10ᵉ source de photos ne coûtera plus rien au cœur.

### Divergence assumée avec la météo : pas de mode automatique

La météo a une réponse « juste » unique (le temps qu’il fait ici) : le cœur peut donc essayer les fournisseurs dans l’ordre et prendre le premier qui répond. **Les photos n’ont pas cette propriété** : « l’album _Vacances 2019_ de mon Immich » n’a aucun équivalent chez un autre fournisseur. Par conséquent :

- le widget en mode fournisseur **épingle toujours** un service (`photo_provider`) et une source (`photo_source_type` + `photo_source_id`) ;
- il n’y a **ni mode automatique, ni repli silencieux** : si le fournisseur épinglé est absent, arrêté ou non configuré, le widget affiche un état explicite (§C.4) plutôt que les photos de quelqu’un d’autre.

## Périmètre

### Dans le périmètre (v1)

- Contrat générique « fournisseur de photos » + lib cœur `gladys.photo` + routes REST (§B).
- Service interne **Immich** : page de configuration (URL + clé d’API + test de connexion), listage des albums, résolution album / souvenirs, proxy image authentifié (§D).
- Widget Photo : choix du mode de source, sélection du fournisseur et de la source, ordre, plafond, légendes automatiques (§C).
- Compatibilité ascendante **totale** du mode « URLs manuelles » : aucun widget existant n’est modifié, aucune migration.

## A. Configuration du widget (modèle de données)

La configuration d’un widget vit dans le JSON des boîtes de `t_dashboard` : **aucune migration de base de données**. Seul le schéma Joi de `server/models/dashboard.js` est étendu.

| Champ | Type | Défaut | Rôle |
| --- | --- | --- | --- |
| `photo_source_mode` | `'manual' | 'provider'` | `'manual'` | Mode de source. **Absent ⇒ `'manual'`** : c’est ce qui garantit la compatibilité ascendante des widgets déjà enregistrés. |
| `photo_provider` | string (nom de service) | — | Fournisseur épinglé, ex. `immich`. Requis si `photo_source_mode === 'provider'`. |
| `photo_source_type` | `'album' | 'memories'` | — | Type de source chez ce fournisseur. Valeur libre côté contrat (§B.2), validée par le fournisseur, pas par le cœur. |
| `photo_source_id` | string ≤ 128 | `''` | Identifiant de la source (UUID d’album Immich). Vide pour une source sans identifiant (`memories`). |
| `photo_order` | `'recent_first' | 'oldest_first' | 'random'` | `'recent_first'` | Ordre d’affichage (§E.2). |
| `photo_max` | entier 1–100 | `50` | Plafond de photos chargées depuis la source (§E.3). Aligné sur le `.max(100)` déjà appliqué à `photos`. |
| `photo_caption_mode` | `'auto' | 'none'` | `'auto'` | En mode fournisseur, légende générée depuis les métadonnées (§E.4) ou aucune légende. |
| `photos`, `photo_fit`, `photo_slideshow_interval`, `photo_show_caption`, `name` | inchangés | — | `photos` n’est lu qu’en mode `manual` ; les autres s’appliquent **aux deux modes**. |

Ajouts au schéma Joi (`server/models/dashboard.js`) :

```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'),

```

Le schéma reste **permissif sur `photo_source_type`** (chaîne bornée, pas un `valid()`) : ajouter une source `favorites` chez un fournisseur ne doit pas exiger une modification du cœur, exactement comme le `type` du manifeste d’intégration externe n’est pas énuméré par le widget.

## B. Contrat « fournisseur de photos » (cœur)

Nouvelle lib `server/lib/photo/`, montée dans `server/lib/index.js` sous `gladys.photo`, sur le modèle de `server/lib/weather/`.

```auto
server/lib/photo/
  index.js // Photo(service) + prototypes
  photo.getProviders.js // énumération duck-typée
  photo.getSources.js // sources sélectionnables d'un fournisseur
  photo.getPhotos.js // résolution source -> liste normalisée
  photo.getImage.js // octets d'une photo -> data URI, avec cache
  photo.normalize.js // normalizeSources / normalizePhotos
  constants.js // regex d'identifiants, plafonds, TTL de cache

```

### B.1 Énumération (duck typing, comme `weather.getProviders`)

```js
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();
}

```

Un service est un fournisseur **s’il expose les trois fonctions** `photo.getSources`, `photo.getPhotos`, `photo.getImage`. `getSources` sert de sonde (un fournisseur incomplet est un bug de fournisseur, pas un cas à gérer au cas par cas dans le cœur) ; le cœur vérifie les deux autres au moment de l’appel et lève `NotFoundError` si elles manquent.

### B.2 Format pivot d’une **source**

```json
{
  "type": "album",
  "id": "0d5f4c2e-…-uuid",
  "label": "Vacances 2019",
  "count": 248
}

```

| Champ | Requis | Normalisation appliquée par le cœur |
| --- | --- | --- |
| `type` | oui | `^[a-z][a-z0-9-]{0,31}$`, sinon la source est **écartée** |
| `id` | oui (peut être `""`) | `^[A-Za-z0-9._:-]{0,128}$`, sinon écartée. `""` = source unique de son type (les souvenirs) |
| `label` | oui | chaîne bornée à 100 caractères, tronquée |
| `count` | non | entier fini ≥ 0, sinon supprimé |

Liste bornée à **200 sources** ; au-delà, tronquée (un utilisateur ne choisit pas dans un menu de 2000 albums — §E.5 traite la recherche).

### B.3 Format pivot d’une **photo**

```json
{
  "id": "3f0a…-uuid",
  "caption": "Rome — 12 août 2019",
  "taken_at": "2019-08-12T14:03:11.000Z"
}

```

| Champ | Requis | Normalisation |
| --- | --- | --- |
| `id` | oui | `^[A-Za-z0-9._:-]{1,128}$`, sinon la photo est **écartée**. C’est le jeton opaque que le widget renverra à `photo.getImage` — le cœur ne l’interprète jamais |
| `caption` | non | chaîne bornée à 200 caractères, tronquée ; vide ⇒ supprimée |
| `taken_at` | non | date ISO valide, sinon supprimée |

Aucune URL ne figure dans le pivot, **par construction** : le navigateur ne doit jamais joindre le fournisseur directement (ni fuite d’IP, ni clé d’API exposée, ni rupture de l’accès distant Gladys Plus). Le tri et le plafond sont appliqués **par le cœur** après normalisation (§E.2, §E.3), pour que tous les fournisseurs se comportent pareil.

### B.4 Format d’une **image**

`photo.getImage` renvoie la chaîne **`"image/jpeg;base64,…"`** — exactement le format déjà produit par `dashboard.getPhoto` et consommé par `PhotoBox`/`EditPhotoBox` en `data:${image}`. Le fournisseur, lui, renvoie un `Buffer` : c’est le cœur qui valide et ré-encode (§B.6), pour que la validation ne dépende jamais du fournisseur.

### B.5 Routes REST

Ajoutées dans `server/api/routes.js` via un `photo.controller.js` (le modèle est `weather.controller.js`), toutes `authenticated: true` sans `admin` : n’importe quel utilisateur configure **son** dashboard, comme pour `GET /api/v1/weather/provider`. La charge utile ne contient rien d’opérationnel (pas d’URL de serveur, pas de clé).

| Route | Paramètres | Réponse |
| --- | --- | --- |
| `GET /api/v1/photo/provider` | — | `[{ "service_name": "immich", "label": "Immich" }]` — même forme que les fournisseurs météo (`label` = nom d’affichage du manifeste pour une intégration externe, `null` pour un service interne, l’i18n du front prenant le relais) |
| `GET /api/v1/photo/source` | `service` (requis) | `[{ 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`, comme le proxy existant) |

`GET /api/v1/dashboard/photo/proxy` **reste inchangé** : c’est le chemin du mode manuel, avec sa protection SSRF, et il n’est pas concerné par ces routes.

Erreurs : format standard Gladys (`errorMiddleware`). `service` inconnu ou non fournisseur → `404 NOT_FOUND`. Fournisseur non configuré → `ServiceNotConfiguredError` (le front affiche l’appel à l’action « configurez Immich »). Échec du tiers → `400` avec `ERROR_MESSAGES.REQUEST_TO_THIRD_PARTY_FAILED`, le même code que le widget météo sait déjà présenter.

### B.6 Ce que le cœur ne fait jamais confiance

Comme `normalizeWeather` pour la météo, tout ce qui revient d’un fournisseur est **normalisé et borné avant d’entrer dans le cœur** :

- listes bornées (200 sources, 100 photos), champs en liste blanche (tout champ inconnu est supprimé), chaînes tronquées, dates validées, identifiants filtrés par regex ;
- **image validée sur les octets décodés** : nombres magiques JPEG / PNG / WebP / AVIF / GIF uniquement (pas de confiance au `Content-Type` du tiers), taille ≤ 25 Mo (aligné sur `MAX_SOURCE_IMAGE_BYTES`), puis ré-encodage systématique par `resizeImageBuffer` en 800×400 JPEG q80. Un fournisseur ne peut donc pas faire servir un SVG, un HTML ou un fichier de 200 Mo par l’origine de Gladys ;
- `photo_id` re-vérifié par le cœur avant tout appel fournisseur : un identifiant hors regex renvoie `404` **sans qu’un seul octet ne parte** vers le fournisseur.

### B.7 Caches (bornés, en mémoire)

| Cache | Clé | TTL | Taille max |
| --- | --- | --- | --- |
| Sources | `service` | 5 min | 1 entrée par fournisseur |
| Liste de photos | `service` + `source_type` + `source_id` + `order` + `limit` | 5 min | 20 entrées (LRU) |
| Image | `service` + `photo_id` | 10 min | 60 entrées (LRU) — même ordre de grandeur que le cache d’images météo (10 min) |

Après ré-encodage, une image pèse ~30–60 Ko : 60 entrées ≈ 3 Mo, acceptable sur un Raspberry Pi. Le cache d’images du **front** (`imageCache` de `PhotoBox`) est lui aussi borné à 60 entrées LRU dans le cadre de ce travail : aujourd’hui il croît sans limite, ce qui passait avec 100 URLs manuelles mais mérite une borne dès lors qu’un album se rafraîchit périodiquement.

Le mode `random` est exclu du cache de liste ou, plus simplement, **tiré côté serveur avec une graine dérivée de la fenêtre de cache** : deux chargements consécutifs à moins de 5 min renvoient donc le même ordre — c’est voulu, sinon la navigation avant/arrière du diaporama sauterait d’une photo à l’autre sans cohérence.

## C. Front

### C.1 Édition du widget (`EditPhotoBox.jsx`)

Un premier select **Source des photos** : _URLs manuelles_ (défaut) / _Depuis une intégration_.

- **URLs manuelles** : l’écran actuel, à l’identique.
- **Depuis une intégration** :
  1. select **Fournisseur** ← `GET /api/v1/photo/provider`. Si la liste est vide : bloc d’aide « Aucune intégration photo n’est installée » avec un lien vers le catalogue d’intégrations.
  2. select **Source** ← `GET /api/v1/photo/source?service=…`, groupé par `type` (`<optgroup>` : _Albums_, _Souvenirs_), libellé `label` + `count` quand il est présent. Écrit `photo_source_type` **et** `photo_source_id` en une seule action.
  3. select **Ordre** (récentes d’abord / anciennes d’abord / aléatoire), champ **Nombre maximum de photos** (1–100, défaut 50), switch **Légendes automatiques**.
  4. **Aperçu** : les 3 premières photos résolues, chargées par `GET /api/v1/photo/image` — même rôle que le `PhotoPreview` du mode manuel (vérifier avant d’enregistrer), sans dupliquer sa logique de debounce puisqu’il n’y a plus de saisie caractère par caractère.

Les options communes (cadrage, intervalle, affichage des légendes, nom du widget) restent affichées dans les deux modes.

### C.2 Exécution (`PhotoBox.jsx`)

`PhotoBox` gagne une étape de **résolution** en amont de son diaporama ; tout ce qui suit (index courant, transitions, boutons, indicateurs, préchargement, cache) est **réutilisé tel quel**.

- Mode `manual` : `photos` vient de la configuration, images via `/api/v1/dashboard/photo/proxy?url=` — inchangé.
- Mode `provider` : au montage, `GET /api/v1/photo/list` → liste de `{ id, caption, taken_at }` en state ; chaque image via `GET /api/v1/photo/image?service=…&photo_id=…`, la clé de cache étant l’URL de la requête. Le préchargement de l’image suivante fonctionne à l’identique.

Le composant travaille donc sur une **liste résolue** commune aux deux modes ; c’est la seule vraie refonte interne, et elle simplifie `getDerivedStateFromProps` (qui borne aujourd’hui l’index à partir des props uniquement).

### C.3 Rafraîchissement

- **Au montage** du widget, et à chaque changement de configuration de la source.
- **Périodiquement** , toutes les **60 minutes** (`PROVIDER_LIST_REFRESH_MS`) : suffisant pour un album qui s’enrichit, et cela rattrape le passage de minuit des souvenirs en moins d’une heure.
- **Au retour à l’index 0** du diaporama si la liste a plus de 60 min : un dashboard laissé allumé sur un mur reste à jour sans horloge supplémentaire.
- Un rafraîchissement qui renvoie une liste **plus courte** que l’index courant ramène l’index dans les bornes (règle existante) ; une liste identique ne déclenche aucun rechargement d’image, le cache faisant son office.

### C.4 États d’affichage

| Situation | Rendu |
| --- | --- |
| Source vide (album vide, aucun souvenir aujourd’hui) | État vide explicite, **pas une erreur** : « Aucune photo dans cette source aujourd’hui. » (clé i18n dédiée, distincte de `emptyPhotos`) |
| Fournisseur non configuré | Message + lien vers la page de configuration de l’intégration |
| Fournisseur absent / arrêté / injoignable | Message d’erreur avec le nom du fournisseur épinglé — **jamais** de repli sur un autre fournisseur |
| Échec d’une image isolée | Comportement actuel : icône d’erreur sur cette photo, le diaporama continue |

## F. Phase 2 — intégrations externes de `type: "photo"` (conception, non implémentée)

Transposition directe de `docs/specs/external-integrations.md` §B.18 (météo) et §B.15 (communication). Rien de ce qui est spécifié en §A–E ne change ; l’implémentation de cette phase mettra à jour `external-integrations.md` dans le même diff, comme sa règle l’exige.

- **Manifeste** : `type: "photo"`. Écran d’installation portant une ligne d’information dédiée (« cette intégration pourra fournir les photos affichées sur vos dashboards »).
- **Service proxy** : expose `photo.getSources` / `photo.getPhotos` / `photo.getImage`, relayés en WebSocket :

| Commande | Charge utile | Ack (`command-result`) | Délai |
| --- | --- | --- | --- |
| `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` (base64 brut, sans préfixe data-URI) | 15 s |

Le délai de 15 s est celui déjà admis pour `camera.get-image` et `weather.get` (appel d’API tierce). L’ordre et le plafond restent appliqués par le cœur : l’intégration reçoit `limit` à titre indicatif (pour ne pas transférer 5000 entrées), le cœur re-tronque après normalisation.

- **Aucune surface « device »** : comme la météo et la communication, une intégration photo n’a ni écran Appareils, ni découverte, ni états — seulement Configuration / Supervision / Logs.
- **Rien de nouveau à valider** : `normalizeSources` / `normalizePhotos` / la validation d’image de §B.6 sont écrites pour être appliquées à **tout** fournisseur dès la v1 — le service interne Immich est traité avec la même méfiance qu’une intégration tierce, ce qui garantit que la phase 2 n’ajoute aucune surface de confiance.
