Contexto
Este documento especifica cómo el widget de Foto del panel de control se alimenta de un proveedor de fotos (« proveedor de fotos ») en lugar de una lista de URLs ingresadas manualmente, y define el contrato que cualquier proveedor — servicio interno o integración externa — debe implementar. Immich es el primer proveedor.
El widget de Foto (box.type = 'photo') ya existe:
- renderizado:
front/src/components/boxs/photo/PhotoBox.jsx(diapositivas, navegación hacia adelante/atrás, indicadores, caché de imágenes, precarga de la siguiente imagen) yEditPhotoBox.jsx(lista de URLs + leyendas, recorte, intervalo, visualización de las leyendas); - modelo:
DASHBOARD_BOX_TYPE.PHOTO = 'photo'(server/utils/constants.js), configuración validada por el esquema Joi deserver/models/dashboard.js(photos: matriz de{ url, caption }, máx. 100,photo_fit,photo_slideshow_interval0–3600,photo_show_caption); - recuperación de imágenes:
GET /api/v1/dashboard/photo/proxy?url=→server/lib/dashboard/dashboard.getPhoto.js. El servidor descarga la imagen (por lo que un NAS local sigue siendo visible a distancia a través de Gladys Plus), la re-encodifica en JPEG 800×400, calidad 80 a través deresizeImageBuffer, y devuelve la cadena"image/jpeg;base64,…"que el front consume ensrc={data:${image}}.
Lo que bloquea hoy. Alimentar el widget desde un servidor Immich es imposible sin código de servidor dedicado, por tres razones acumulativas:
- Autenticación. Immich exige un encabezado
x-api-keyen cada solicitud. El proxy actual hace unGETdesnudo, sin encabezado: estructuralmente no puede hablar con Immich. - Red. El proxy actual bloquea intencionalmente el bucle local y el link-local (protección SSRF, la URL proviene de un campo libre). Sin embargo, un Immich autoalojado es muy a menudo accesible en
http://localhost:2283,http://immich-server:2283(red Docker) o en una IP privada: la ruta « URL manual » es la herramienta equivocada para una dirección configurada una vez por un administrador. - Dinamismo. Una lista de URLs está fija. Un álbum de Immich se enriquece, y los recuerdos « ese día » cambian todos los días: se debe resolver la fuente en una lista de fotos en tiempo de ejecución, no en tiempo de configuración.
Principio rector: el núcleo no conoce a ningún proveedor por su nombre
El precedente es el widget del clima (docs/specs/external-integrations.md §B.18): weather.get enumera el stateManager y retiene cualquier servicio que exponga weather.get(options), el widget eventualmente fija un proveedor (GET /api/v1/weather/provider, luego ?service=), y un formato pivote normalizado por el núcleo aísla la UI de los payloads de cada proveedor. No hay getService('openweather') en duro.
Esta especificación transpone exactamente este modelo a las fotos:
- el núcleo expone una capacidad
photo.*(gladys.photo) que enumera los servicios que exponenphoto.getSources(...)/photo.getPhotos(...)/photo.getImage(...); - Immich es un proveedor entre otros, implementado en v1 como servicio interno (
server/services/immich), exactamente comoopenweatherlo es para el clima; - una integración externa de
type: "photo"(Google Photos, PhotoPrism, Synology Photos, Nextcloud Photos…) podrá implementar el mismo contrato sin tocar el núcleo ni el widget (fase 2, §F).
El costo es el mismo que para el clima: una lib núcleo genérica + un servicio. El beneficio es que la 2ª, 3ª, 10ª fuente de fotos ya no costará nada al núcleo.
Divergencia asumida con el clima: sin modo automático
El clima tiene una respuesta « correcta » única (el tiempo que hace aquí): el núcleo puede intentar los proveedores en orden y tomar el primero que responda. Las fotos no tienen esta propiedad: « el álbum Vacaciones 2019 de mi Immich » no tiene equivalente en otro proveedor. Por lo tanto:
- el widget en modo proveedor siempre fija un servicio (
photo_provider) y una fuente (photo_source_type+photo_source_id); - no hay modo automático ni caída silenciosa: si el proveedor fijado está ausente, detenido o no configurado, el widget muestra un estado explícito (§C.4) en lugar de las fotos de otra persona.
Alcance
Dentro del alcance (v1)
- Contrato genérico « proveedor de fotos » + lib núcleo
gladys.photo+ rutas REST (§B). - Servicio interno Immich: página de configuración (URL + clave de API + prueba de conexión), listado de álbumes, resolución de álbum/recuerdos, proxy de imagen autenticado (§D).
- Widget de Foto: elección del modo de fuente, selección del proveedor y de la fuente, orden, límite, leyendas automáticas (§C).
- Compatibilidad ascendente total del modo « URLs manuales »: ningún widget existente se modifica, ninguna migración.
A. Configuración del widget (modelo de datos)
La configuración de un widget vive en el JSON de las cajas de t_dashboard: ninguna migración de base de datos. Solo el esquema Joi de server/models/dashboard.js se extiende.
| Campo | Tipo | Valor por defecto | Rol |
|---|---|---|---|
photo_source_mode |
'manual' | 'provider' |
'manual' |
Modo de fuente. Ausente ⇒ 'manual': es lo que garantiza la compatibilidad ascendente de los widgets ya registrados. |
photo_provider |
string (nombre de servicio) | — | Proveedor fijado, ej. immich. Requerido si photo_source_mode === 'provider'. |
photo_source_type |
'album' | 'memories' |
— | Tipo de fuente en este proveedor. Valor libre del lado del contrato (§B.2), validado por el proveedor, no por el núcleo. |
photo_source_id |
string ≤ 128 | '' |
Identificador de la fuente (UUID de álbum de Immich). Vacío para una fuente sin identificador (memories). |
photo_order |
'recent_first' | 'oldest_first' | 'random' |
'recent_first' |
Orden de visualización (§E.2). |
photo_max |
entero 1–100 | 50 |
Límite de fotos cargadas desde la fuente (§E.3). Alineado con el .max(100) ya aplicado a photos. |
photo_caption_mode |
'auto' | 'none' |
'auto' |
En modo proveedor, leyenda generada desde los metadatos (§E.4) o ninguna leyenda. |
photos, photo_fit, photo_slideshow_interval, photo_show_caption, name |
inalterados | — | photos solo se lee en modo manual; los demás se aplican a los dos modos. |
Añadidos al esquema 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'),
El esquema sigue siendo permisivo con photo_source_type (cadena acotada, no un valid()): añadir una fuente favorites en un proveedor no debe exigir una modificación del núcleo, exactamente como el type del manifiesto de integración externa no está enumerado por el widget.
B. Contrato « proveedor de fotos » (núcleo)
Nueva lib server/lib/photo/, montada en server/lib/index.js bajo gladys.photo, sobre el modelo de server/lib/weather/.
server/lib/photo/
index.js // Photo(service) + prototipos
photo.getProviders.js // enumeración duck-typed
photo.getSources.js // fuentes seleccionables de un proveedor
photo.getPhotos.js // resolución de fuente -> lista normalizada
photo.getImage.js // bytes de una foto -> data URI, con caché
photo.normalize.js // normalizeSources / normalizePhotos
constants.js // regex de identificadores, límites, TTL de caché
B.1 Enumeración (duck typing, como 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 servicio es un proveedor si expone las tres funciones photo.getSources, photo.getPhotos, photo.getImage. getSources actúa como sonda (un proveedor incompleto es un error de proveedor, no un caso a manejar individualmente en el núcleo); el núcleo verifica las otras dos en el momento de la llamada y lanza NotFoundError si faltan.
B.2 Formato pivote de una fuente
{
"type": "album",
"id": "0d5f4c2e-…-uuid",
"label": "Vacaciones 2019",
"count": 248
}
| Campo | Requerido | Normalización aplicada por el núcleo |
|---|---|---|
type |
sí | ^[a-z][a-z0-9-]{0,31}$, de lo contrario la fuente es descartada |
id |
sí (puede ser "") |
^[A-Za-z0-9._:-]{0,128}$, de lo contrario descartada. "" = fuente única de su tipo (los recuerdos) |
label |
sí | cadena limitada a 100 caracteres, truncada |
count |
no | entero finito ≥ 0, de lo contrario eliminado |
Lista limitada a 200 fuentes; más allá, truncada (un usuario no elige en un menú de 2000 álbumes — §E.5 trata la búsqueda).
B.3 Formato pivote de una foto
{
"id": "3f0a…-uuid",
"caption": "Roma — 12 de agosto de 2019",
"taken_at": "2019-08-12T14:03:11.000Z"
}
| Campo | Requerido | Normalización |
|---|---|---|
id |
sí | ^[A-Za-z0-9._:-]{1,128}$, de lo contrario la foto es descartada. Es el token opaco que el widget devolverá a photo.getImage — el núcleo nunca lo interpreta |
caption |
no | cadena limitada a 200 caracteres, truncada; vacía ⇒ eliminada |
taken_at |
no | fecha ISO válida, de lo contrario eliminada |
Ninguna URL figura en el pivote, por construcción: el navegador nunca debe unirse al proveedor directamente (ni fuga de IP, ni clave de API expuesta, ni ruptura del acceso remoto Gladys Plus). La clasificación y el límite se aplican por el núcleo después de la normalización (§E.2, §E.3), para que todos los proveedores se comporten igual.
B.4 Formato de una imagen
photo.getImage devuelve la cadena "image/jpeg;base64,…" — exactamente el formato ya producido por dashboard.getPhoto y consumido por PhotoBox/EditPhotoBox en data:${image}. El proveedor, en cambio, devuelve un Buffer: es el núcleo el que valida y re-codifica (§B.6), para que la validación nunca dependa del proveedor.
B.5 Rutas REST
Añadidas en server/api/routes.js a través de un photo.controller.js (el modelo es weather.controller.js), todas authenticated: true sin admin: cualquier usuario configura su dashboard, como para GET /api/v1/weather/provider. La carga útil no contiene nada operativo (ninguna URL de servidor, ninguna clave).
| Ruta | Parámetros | Respuesta |
|---|---|---|
GET /api/v1/photo/provider |
— | [{ "service_name": "immich", "label": "Immich" }] — misma forma que los proveedores meteorológicos (label = nombre de visualización del manifiesto para una integración externa, null para un servicio interno, la i18n del front toma el relevo) |
GET /api/v1/photo/source |
service (requerido) |
[{ 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, como el proxy existente) |
GET /api/v1/dashboard/photo/proxy se mantiene sin cambios: es el camino del modo manual, con su protección SSRF, y no está afectado por estas rutas.
Errores: formato estándar Gladys (errorMiddleware). service desconocido o no proveedor → 404 NOT_FOUND. Proveedor no configurado → ServiceNotConfiguredError (el front muestra la llamada a la acción « configurez Immich »). Fallo del tercero → 400 con ERROR_MESSAGES.REQUEST_TO_THIRD_PARTY_FAILED, el mismo código que el widget meteorológico ya sabe presentar.
B.6 Lo que el núcleo nunca confía
Como normalizeWeather para el clima, todo lo que proviene de un proveedor es normalizado y limitado antes de entrar en el núcleo:
- listas limitadas (200 fuentes, 100 fotos), campos en lista blanca (todo campo desconocido es eliminado), cadenas truncadas, fechas validadas, identificadores filtrados por regex;
- imagen validada en los bytes decodificados: solo números mágicos JPEG / PNG / WebP / AVIF / GIF (no confianza en el
Content-Typedel tercero), tamaño ≤ 25 Mo (alineado conMAX_SOURCE_IMAGE_BYTES), luego re-codificación sistemática porresizeImageBufferen 800×400 JPEG q80. Un proveedor no puede hacer servir un SVG, un HTML o un archivo de 200 Mo por el origen de Gladys; photo_idre-verificado por el núcleo antes de cualquier llamada al proveedor: un identificador fuera de regex devuelve404sin que un solo byte se envíe al proveedor.
B.7 Cachés (limitados, en memoria)
| Cache | Clave | TTL | Tamaño máximo |
|---|---|---|---|
| Fuentes | service |
5 min | 1 entrada por proveedor |
| Lista de fotos | service + source_type + source_id + order + limit |
5 min | 20 entradas (LRU) |
| Imagen | service + photo_id |
10 min | 60 entradas (LRU) — mismo orden de magnitud que el caché de imágenes meteorológicas (10 min) |
Después de la re-codificación, una imagen pesa ~30–60 Ko: 60 entradas ≈ 3 Mo, aceptable en una Raspberry Pi. El caché de imágenes del front (imageCache de PhotoBox) también está limitado a 60 entradas LRU en el marco de este trabajo: hoy crece sin límite, lo que pasaba con 100 URLs manuales pero merece un límite tan pronto como un álbum se actualiza periódicamente.
El modo random está excluido del caché de lista o, más simplemente, extraído del lado del servidor con una semilla derivada de la ventana de caché: dos cargas consecutivas a menos de 5 min devuelven el mismo orden — es intencional, de lo contrario la navegación adelante/atrás del diapositivas saltaría de una foto a otra sin coherencia.
C. Front
C.1 Edición del widget (EditPhotoBox.jsx)
Un primer select Fuente de fotos: URLs manuales (por defecto) / Desde una integración.
- URLs manuales: la pantalla actual, idéntica.
- Desde una integración:
- select Proveedor ←
GET /api/v1/photo/provider. Si la lista está vacía: bloque de ayuda « Ninguna integración de fotos está instalada » con un enlace al catálogo de integraciones. - select Fuente ←
GET /api/v1/photo/source?service=…, agrupado portype: *Álbumes*, *Recuerdos*), etiquetalabel+countcuando esté presente. Escribephoto_source_type**y**photo_source_id` en una sola acción. - Selecciona Orden (recientes primero / antiguas primero / aleatorio), campo Número máximo de fotos (1–100, por defecto 50), interruptor Leyendas automáticas.
- Vista previa : las 3 primeras fotos resueltas, cargadas por
GET /api/v1/photo/image— mismo rol que elPhotoPreviewdel modo manual (verificar antes de guardar), sin duplicar su lógica de debounce ya que no hay más entrada carácter por carácter.
- select Proveedor ←
Las opciones comunes (encuadre, intervalo, visualización de leyendas, nombre del widget) siguen mostradas en los dos modos.
C.2 Ejecución (PhotoBox.jsx)
PhotoBox gana una etapa de resolución antes de su diapositiva; todo lo que sigue (índice actual, transiciones, botones, indicadores, precarga, caché) es reutilizado tal cual.
- Modo
manual:photosproviene de la configuración, imágenes a través de/api/v1/dashboard/photo/proxy?url=— sin cambios. - Modo
provider: al montar,GET /api/v1/photo/list→ lista de{ id, caption, taken_at }en estado; cada imagen a través deGET /api/v1/photo/image?service=…&photo_id=…, la clave de caché siendo la URL de la solicitud. La precarga de la siguiente imagen funciona de manera idéntica.
El componente trabaja entonces sobre una lista resuelta común a los dos modos; es la única verdadera refactorización interna, y simplifica getDerivedStateFromProps (que hoy limita el índice a partir de las props únicamente).
C.3 Actualización
- Al montar el widget, y cada vez que cambia la configuración de la fuente.
- Periódicamente, cada 60 minutos (
PROVIDER_LIST_REFRESH_MS) : suficiente para un álbum que se enriquece, y esto recupera el paso de medianoche de los recuerdos en menos de una hora. - Al volver al índice 0 del diapositiva si la lista tiene más de 60 min : un dashboard dejado encendido en una pared se mantiene actualizado sin reloj adicional.
- Una actualización que devuelve una lista más corta que el índice actual devuelve el índice dentro de los límites (regla existente); una lista idéntica no desencadena ninguna recarga de imagen, la caché haciendo su trabajo.
C.4 Estados de visualización
| Situación | Renderizado |
|---|---|
| Fuente vacía (álbum vacío, ningún recuerdo hoy) | Estado vacío explícito, no un error : « No hay fotos en esta fuente hoy. » (clave i18n dedicada, distinta de emptyPhotos) |
| Proveedor no configurado | Mensaje + enlace a la página de configuración de la integración |
| Proveedor ausente / detenido / inalcanzable | Mensaje de error con el nombre del proveedor destacado — nunca un retroceso a otro proveedor |
| Fallo de una imagen aislada | Comportamiento actual : ícono de error en esta foto, el diapositiva continúa |
F. Fase 2 — integraciones externas de type: "photo" (diseño, no implementado)
Transposición directa de docs/specs/external-integrations.md §B.18 (meteorología) y §B.15 (comunicación). Nada de lo que se especifica en §A–E cambia; la implementación de esta fase actualizará external-integrations.md en el mismo diff, como su regla lo exige.
- Manifiesto :
type: "photo". Pantalla de instalación que lleva una línea de información dedicada (« esta integración podrá proporcionar las fotos mostradas en sus dashboards »). - Servicio proxy : expone
photo.getSources/photo.getPhotos/photo.getImage, relayados en WebSocket :
| Comando | Carga útil | Ack (command-result) |
Retraso |
|---|---|---|---|
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 bruto, sin prefijo data-URI) |
15 s |
El retraso de 15 s es el ya admitido para camera.get-image y weather.get (llamada de API de terceros). El orden y el límite siguen aplicados por el núcleo : la integración recibe limit a título indicativo (para no transferir 5000 entradas), el núcleo re-trunca después de normalización.
- Ninguna superficie « device » : como la meteorología y la comunicación, una integración de fotos no tiene ni pantalla Dispositivos, ni descubrimiento, ni estados — solo Configuración / Supervisión / Logs.
- Nada nuevo que validar :
normalizeSources/normalizePhotos/ la validación de imagen de §B.6 están escritas para ser aplicadas a todo proveedor desde la v1 — el servicio interno Immich es tratado con la misma desconfianza que una integración de terceros, lo que garantiza que la fase 2 no añade ninguna superficie de confianza.