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) etEditPhotoBox.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 deserver/models/dashboard.js(photos: tableau de{ url, caption }, max 100,photo_fit,photo_slideshow_interval0–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 viaresizeImageBuffer, et renvoie la chaîne"image/jpeg;base64,…"que le front consomme ensrc={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 :
- Authentification. Immich exige un en-tête
x-api-keysur chaque requête. Le proxy actuel fait unGETnu, sans en-tête : il ne peut structurellement pas parler à Immich. - 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. - 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 exposantphoto.getSources(...)/photo.getPhotos(...)/photo.getImage(...); - Immich est un fournisseur parmi d’autres, implémenté en v1 comme service interne (
server/services/immich), exactement commeopenweatherl’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-Typedu tiers), taille ≤ 25 Mo (aligné surMAX_SOURCE_IMAGE_BYTES), puis ré-encodage systématique parresizeImageBufferen 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_idre-vérifié par le cœur avant tout appel fournisseur : un identifiant hors regex renvoie404sans 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 :
- 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. - select Source ←
GET /api/v1/photo/source?service=…, groupé partype(<optgroup>: Albums, Souvenirs), libellélabel+countquand il est présent. Écritphoto_source_typeetphoto_source_iden une seule action. - 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.
- Aperçu : les 3 premières photos résolues, chargées par
GET /api/v1/photo/image— même rôle que lePhotoPreviewdu 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.
- select Fournisseur ←
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:photosvient 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 viaGET /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.