Widget Foto - problemas de integración de fuentes (Immich)

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) y EditPhotoBox.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 de server/models/dashboard.js (photos: matriz de { url, caption }, máx. 100, photo_fit, photo_slideshow_interval 0–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 de resizeImageBuffer, y devuelve la cadena "image/jpeg;base64,…" que el front consume en src={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:

  1. Autenticación. Immich exige un encabezado x-api-key en cada solicitud. El proxy actual hace un GET desnudo, sin encabezado: estructuralmente no puede hablar con Immich.
  2. 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.
  3. 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 exponen photo.getSources(...) / photo.getPhotos(...) / photo.getImage(...);
  • Immich es un proveedor entre otros, implementado en v1 como servicio interno (server/services/immich), exactamente como openweather lo 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-Type del tercero), tamaño ≤ 25 Mo (alineado con MAX_SOURCE_IMAGE_BYTES), luego re-codificación sistemática por resizeImageBuffer en 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_id re-verificado por el núcleo antes de cualquier llamada al proveedor: un identificador fuera de regex devuelve 404 sin 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:
    1. 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.
    2. select Fuente ← GET /api/v1/photo/source?service=…, agrupado por type: *Álbumes*, *Recuerdos*), etiquetalabel+countcuando esté presente. Escribephoto_source_type**y**photo_source_id` en una sola acción.
    3. Selecciona Orden (recientes primero / antiguas primero / aleatorio), campo Número máximo de fotos (1–100, por defecto 50), interruptor Leyendas automáticas.
    4. Vista previa : las 3 primeras fotos resueltas, cargadas por GET /api/v1/photo/image — mismo rol que el PhotoPreview del 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.

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 : photos proviene 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 de GET /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.