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

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) :

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/.

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)

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

{
  "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

{
  "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 FournisseurGET /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 SourceGET /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.