Widget Foto - Probleme mit Integrationsquellen (Immich)

Kontext

Dieses Dokument spezifiziert, wie sich das Foto-Widget des Dashboards von einem Fotoanbieter („photo provider“) statt von einer manuell eingegebenen Liste von URLs versorgt, und definiert den Vertrag, den jeder Anbieter – ob interner Dienst oder externe Integration – implementieren muss. Immich ist der erste Anbieter.

Das Foto-Widget (box.type = 'photo') existiert bereits:

  • Darstellung: front/src/components/boxs/photo/PhotoBox.jsx (Diashow, Vor-/Rückwärtsnavigation, Indikatoren, Bildcache, Vorladen des nächsten Bildes) und EditPhotoBox.jsx (Liste von URLs + Bildunterschriften, Bildausschnitt, Intervall, Anzeige der Bildunterschriften);
  • Modell: DASHBOARD_BOX_TYPE.PHOTO = 'photo' (server/utils/constants.js), Konfiguration validiert durch das Joi-Schema von server/models/dashboard.js (photos: Array von { url, caption }, max 100, photo_fit, photo_slideshow_interval 0–3600, photo_show_caption);
  • Abruf der Bilder: GET /api/v1/dashboard/photo/proxy?url= → server/lib/dashboard/dashboard.getPhoto.js. Der Server lädt das Bild herunter (daher bleibt ein lokales NAS über Gladys Plus auch aus der Ferne sichtbar), re-encodiert es als JPEG 800×400, Qualität 80 über resizeImageBuffer, und gibt die Zeichenkette "image/jpeg;base64,…" zurück, die das Frontend in src={data:${image}} konsumiert.

Was heute blockiert. Die Versorgung des Widgets aus einem Immich-Server ist ohne dedizierten Server-Code aus drei kumulativen Gründen unmöglich:

  1. Authentifizierung. Immich erfordert einen Header x-api-key für jede Anfrage. Der aktuelle Proxy macht ein nacktes GET, ohne Header: Er kann strukturell nicht mit Immich kommunizieren.
  2. Netzwerk. Der aktuelle Proxy blockiert absichtlich die lokale Schleife und das Link-Local (SSRF-Schutz, da die URL aus einem freien Feld kommt). Ein selbst gehosteter Immich ist jedoch sehr oft unter http://localhost:2283, http://immich-server:2283 (Docker-Netzwerk) oder einer privaten IP erreichbar: Der Pfad „manuelle URL“ ist das falsche Werkzeug für eine Adresse, die einmal von einem Administrator konfiguriert wird.
  3. Dynamik. Eine Liste von URLs ist statisch. Ein Immich-Album wird erweitert, und die Erinnerungen „an diesem Tag“ ändern sich jeden Tag: Die Quelle muss zur Laufzeit in eine Liste von Fotos aufgelöst werden, nicht bei der Konfiguration.

Leitprinzip: Der Kern kennt keinen Anbieter beim Namen

Das vorherige Beispiel ist das Wetter-Widget (docs/specs/external-integrations.md §B.18): weather.get listet den stateManager auf und behält jeden Dienst bei, der weather.get(options) exponiert, das Widget kann optional einen Anbieter festlegen (GET /api/v1/weather/provider, dann ?service=), und ein Pivot-Format, das vom Kern normalisiert wird, isoliert die UI von den Payloads jedes Anbieters. Kein hartcodiertes getService('openweather').

Diese Spezifikation überträgt genau dieses Modell auf Fotos:

  • Der Kern exponiert eine Fähigkeit photo.* (gladys.photo), die Dienste auflistet, die photo.getSources(...) / photo.getPhotos(...) / photo.getImage(...) exponieren;
  • Immich ist ein Anbieter unter vielen, implementiert in v1 als interner Dienst (server/services/immich), genau wie openweather für das Wetter;
  • Eine externe Integration vom Typ "photo" (Google Photos, PhotoPrism, Synology Photos, Nextcloud Photos…) kann denselben Vertrag implementieren ohne den Kern oder das Widget zu berühren (Phase 2, §F).

Der Aufwand ist derselbe wie beim Wetter: eine generische Kern-Bibliothek + ein Dienst. Der Nutzen ist, dass die 2., 3., 10. Fotoquelle den Kern nicht mehr belastet.

Bewusst abweichend vom Wetter: Kein automatischer Modus

Das Wetter hat eine einzig „richtige“ Antwort (das Wetter hier): Der Kern kann daher die Anbieter in der Reihenfolge versuchen und den ersten nehmen, der antwortet. Fotos haben diese Eigenschaft nicht: „Das Album Urlaub 2019 meines Immich“ hat kein Äquivalent bei einem anderen Anbieter. Daher:

  • Das Widget im Anbieter-Modus pinnt immer einen Dienst (photo_provider) und eine Quelle (photo_source_type + photo_source_id) fest;
  • Es gibt weder einen automatischen Modus noch einen stillen Fallback: Wenn der gepinnte Anbieter fehlt, gestoppt oder nicht konfiguriert ist, zeigt das Widget einen expliziten Status (§C.4) an, anstatt die Fotos von jemand anderem anzuzeigen.

Geltungsbereich

Im Geltungsbereich (v1)

  • Generischer Vertrag „Fotoanbieter“ + Kern-Bibliothek gladys.photo + REST-Routen (§B).
  • Interner Dienst Immich: Konfigurationsseite (URL + API-Schlüssel + Verbindungstest), Auflistung der Alben, Auflösung Album / Erinnerungen, authentifizierter Bild-Proxy (§D).
  • Foto-Widget: Auswahl des Quellmodus, Auswahl des Anbieters und der Quelle, Reihenfolge, Obergrenze, automatische Bildunterschriften (§C).
  • Vollständige Abwärtskompatibilität des Modus „manuelle URLs“: Kein bestehendes Widget wird verändert, keine Migration.

A. Widget-Konfiguration (Datenmodell)

Die Konfiguration eines Widgets befindet sich im JSON der Boxen von t_dashboard: keine Datenbankmigration. Nur das Joi-Schema von server/models/dashboard.js wird erweitert.

Feld Typ Standard Rolle
photo_source_mode 'manual' | 'provider' 'manual' Quellmodus. Fehlend ⇒ 'manual': Dies garantiert die Abwärtskompatibilität bereits registrierter Widgets.
photo_provider Zeichenkette (Dienstname) — Gepinnter Anbieter, z. B. immich. Erforderlich, wenn photo_source_mode === 'provider'.
photo_source_type 'album' | 'memories' — Quelltyp bei diesem Anbieter. Freier Wert auf Vertragsseite (§B.2), validiert durch den Anbieter, nicht durch den Kern.
photo_source_id Zeichenkette ≤ 128 '' Identifikator der Quelle (UUID des Immich-Albums). Leer für eine Quelle ohne Identifikator (memories).
photo_order 'recent_first' | 'oldest_first' | 'random' 'recent_first' Anzeigereihenfolge (§E.2).
photo_max Ganzzahl 1–100 50 Obergrenze der von der Quelle geladenen Fotos (§E.3). Abgestimmt auf das bereits auf photos angewendete .max(100).
photo_caption_mode 'auto' | 'none' 'auto' Im Anbieter-Modus: Bildunterschrift generiert aus den Metadaten (§E.4) oder keine Bildunterschrift.
photos, photo_fit, photo_slideshow_interval, photo_show_caption, name unverändert — photos wird nur im Modus manual gelesen; die anderen gelten für beide Modi.

Ergänzungen zum Joi-Schema (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'),

Das Schema bleibt permissiv bei photo_source_type (begrenzte Zeichenkette, kein valid()): Das Hinzufügen einer Quelle favorites bei einem Anbieter sollte keine Änderung des Kerns erfordern, genau wie der type des Manifests der externen Integration nicht vom Widget aufgezählt wird.

B. Vertrag „Fotoanbieter“ (Kern)

Neue Bibliothek server/lib/photo/, eingebunden in server/lib/index.js unter gladys.photo, nach dem Vorbild von server/lib/weather/.

server/lib/photo/
  index.js                 // Photo(service) + Prototypen
  photo.getProviders.js    // duck-typisierte Aufzählung
  photo.getSources.js      // auswählbare Quellen eines Anbieters
  photo.getPhotos.js       // Auflösung Quelle -> normalisierte Liste
  photo.getImage.js        // Bytes eines Fotos -> data URI, mit Cache
  photo.normalize.js       // normalizeSources / normalizePhotos
  constants.js             // Regex von Identifikatoren, Obergrenzen, Cache-TTL

B.1 Aufzählung (Duck-Typing, wie 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();
}

Ein Dienst ist ein Anbieter, wenn er die drei Funktionen photo.getSources, photo.getPhotos und photo.getImage bereitstellt. getSources dient als Sonde (ein unvollständiger Anbieter ist ein Anbieterfehler, kein Einzelfall im Kern zu behandeln); der Kern überprüft die beiden anderen zum Zeitpunkt des Aufrufs und wirft NotFoundError auf, wenn sie fehlen.

B.2 Pivot-Format einer Quelle

{
  "type": "album",
  "id": "0d5f4c2e-…-uuid",
  "label": "Urlaub 2019",
  "count": 248
}
Feld Erforderlich Normalisierung durch den Kern angewendet
type ja ^[a-z][a-z0-9-]{0,31}$, andernfalls wird die Quelle ausgeschlossen
id ja (kann «  » sein) ^[A-Za-z0-9._:-]{0,128}$, andernfalls ausgeschlossen. «  » = einzige Quelle ihres Typs (die Erinnerungen)
label ja Zeichenkette auf 100 Zeichen begrenzt, gekürzt
count nein endliche ganze Zahl ≥ 0, andernfalls gelöscht

Liste auf 200 Quellen begrenzt; darüber hinaus gekürzt (ein Benutzer wählt nicht aus einem Menü mit 2000 Alben — §E.5 behandelt die Suche).

B.3 Pivot-Format eines Fotos

{
  "id": "3f0a…-uuid",
  "caption": "Rom — 12. August 2019",
  "taken_at": "2019-08-12T14:03:11.000Z"
}
Feld Erforderlich Normalisierung
id ja ^[A-Za-z0-9._:-]{1,128}$, andernfalls wird das Foto ausgeschlossen. Dies ist der undurchsichtige Token, den das Widget an photo.getImage zurücksendet — der Kern interpretiert ihn nie
caption nein Zeichenkette auf 200 Zeichen begrenzt, gekürzt; leer ⇒ gelöscht
taken_at nein gültiges ISO-Datum, andernfalls gelöscht

Keine URL ist im Pivot enthalten, konstruktionsbedingt: Der Browser darf den Anbieter nie direkt kontaktieren (keine IP-Leckage, keine API-Schlüssel, keine Unterbrechung des entfernten Gladys Plus-Zugangs). Die Sortierung und die Obergrenze werden durch den Kern nach der Normalisierung angewendet (§E.2, §E.3), damit sich alle Anbieter gleich verhalten.

B.4 Format eines Bildes

photo.getImage gibt die Zeichenkette "image/jpeg;base64,…" zurück — genau das Format, das bereits von dashboard.getPhoto produziert und von PhotoBox/EditPhotoBox in data:${image} konsumiert wird. Der Anbieter gibt jedoch einen Buffer zurück: Der Kern validiert und re-kodiert (§B.6), damit die Validierung nie vom Anbieter abhängt.

B.5 REST-Routen

Hinzugefügt in server/api/routes.js über ein photo.controller.js (das Modell ist weather.controller.js), alle authenticated: true ohne admin: Jeder Benutzer konfiguriert seine Dashboard, wie bei GET /api/v1/weather/provider. Die Nutzlast enthält nichts Operatives (keine Server-URL, keinen Schlüssel).

Route Parameter Antwort
GET /api/v1/photo/provider — [{ "service_name": "immich", "label": "Immich" }] — dieselbe Form wie die Wetteranbieter (label = Anzeigename des Manifests für eine externe Integration, null für einen internen Dienst, das Frontend-i18n übernimmt)
GET /api/v1/photo/source service (erforderlich) [{ 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, wie der bestehende Proxy)

GET /api/v1/dashboard/photo/proxy bleibt unverändert: Dies ist der Pfad des manuellen Modus mit seinem SSRF-Schutz, und er ist nicht von diesen Routen betroffen.

Fehler: Standardformat Gladys (errorMiddleware). Unbekannter oder kein Anbieter service → 404 NOT_FOUND. Nicht konfigurierter Anbieter → ServiceNotConfiguredError (das Frontend zeigt den Aufruf zum Handeln „Konfigurieren Sie Immich“). Dritter Anbieter fehlgeschlagen → 400 mit ERROR_MESSAGES.REQUEST_TO_THIRD_PARTY_FAILED, derselbe Code, den das Wetter-Widget bereits anzeigen kann.

B.6 Wem der Kern nie vertraut

Wie normalizeWeather für das Wetter wird alles, was von einem Anbieter zurückkommt, normalisiert und begrenzt, bevor es in den Kern eintritt:

  • begrenztes Listen (200 Quellen, 100 Fotos), whitelist-Felder (alle unbekannten Felder werden gelöscht), gekürzte Zeichenketten, validierte Daten, durch Regex gefilterte Identifikatoren;
  • Bild validiert auf den decodierten Bytes: Nur JPEG / PNG / WebP / AVIF / GIF Magic Numbers (kein Vertrauen in den Content-Type des Dritten), Größe ≤ 25 MB (abgestimmt auf MAX_SOURCE_IMAGE_BYTES), dann systematische Re-Kodierung durch resizeImageBuffer in 800×400 JPEG q80. Ein Anbieter kann daher kein SVG, kein HTML oder eine 200 MB-Datei über die Gladys-Quelle bereitstellen;
  • photo_id vom Kern vor jedem Anbieteraufruf erneut überprüft: Ein außerhalb der Regex liegender Identifikator gibt 404 zurück ohne dass ein einziges Byte zum Anbieter gesendet wird.

B.7 Caches (begrenzt, im Speicher)

Cache Schlüssel TTL Max. Größe
Quellen service 5 min 1 Eintrag pro Anbieter
Fotoliste service + source_type + source_id + order + limit 5 min 20 Einträge (LRU)
Bild service + photo_id 10 min 60 Einträge (LRU) — gleiche Größenordnung wie der Wetterbild-Cache (10 min)

Nach der Re-Kodierung wiegt ein Bild ~30–60 KB: 60 Einträge ≈ 3 MB, akzeptabel auf einem Raspberry Pi. Der Bild-Cache des Frontends (imageCache von PhotoBox) ist ebenfalls auf 60 LRU-Einträge in diesem Rahmen begrenzt: Heute wächst er ohne Grenze, was mit 100 manuellen URLs funktionierte, aber eine Grenze verdient, sobald ein Album periodisch aktualisiert wird.

Der Modus random ist vom Listencache ausgeschlossen oder, einfacher gesagt, serverseitig mit einem von der Cache-Fenster abgeleiteten Seed gezogen: Zwei aufeinanderfolgende Ladevorgänge innerhalb von 5 Minuten geben daher dieselbe Reihenfolge zurück — das ist beabsichtigt, andernfalls würde die Vorwärts/Rückwärts-Navigation des Diashows von einem Foto zum anderen ohne Kohärenz springen.

C. Frontend

C.1 Bearbeitung des Widgets (EditPhotoBox.jsx)

Ein erstes Dropdown-Menü Fotoquelle: Manuelle URLs (Standard) / Aus einer Integration.

  • Manuelle URLs: Der aktuelle Bildschirm, identisch.
  • Aus einer Integration:
    1. Dropdown-Menü Anbieter ← GET /api/v1/photo/provider. Wenn die Liste leer ist: Hilfeblock „Keine Fotointegration ist installiert“ mit einem Link zum Integrationskatalog.
    2. Dropdown-Menü Quelle ← GET /api/v1/photo/source?service=…, gruppiert nach type (: *Alben*, *Souvenirs*), Bezeichnunglabel+count, wenn vorhanden. Schreibt photo_source_type**und**photo_source_id` in einer einzigen Aktion.
    3. Wählen Sie Reihenfolge (neueste zuerst / älteste zuerst / zufällig), Feld Maximale Anzahl von Fotos (1–100, Standard 50), Schalter Automatische Beschriftungen.
    4. Vorschau : die ersten 3 aufgelösten Fotos, geladen über GET /api/v1/photo/image — dieselbe Rolle wie der PhotoPreview im manuellen Modus (überprüfen, bevor gespeichert wird), ohne die Logik des Debouncings zu duplizieren, da es keine Eingabe Zeichen für Zeichen mehr gibt.

Die gemeinsamen Optionen (Bildausschnitt, Intervall, Anzeige der Beschriftungen, Name des Widgets) bleiben in beiden Modi angezeigt.

C.2 Ausführung (PhotoBox.jsx)

PhotoBox erhält einen Schritt der Auflösung vor seinem Diashow; alles, was folgt (aktueller Index, Übergänge, Schaltflächen, Indikatoren, Vorladen, Cache), wird wie es ist wiederverwendet.

  • Modus manual : photos kommt aus der Konfiguration, Bilder über /api/v1/dashboard/photo/proxy?url= — unverändert.
  • Modus provider : beim Einhängen, GET /api/v1/photo/list → Liste von { id, caption, taken_at } im Zustand; jedes Bild über GET /api/v1/photo/image?service=…&photo_id=…, wobei der Cache-Schlüssel die URL der Anfrage ist. Das Vorladen des nächsten Bildes funktioniert identisch.

Die Komponente arbeitet also an einer aufgelösten Liste, die beiden Modi gemeinsam ist; dies ist die einzige echte interne Überarbeitung, und sie vereinfacht getDerivedStateFromProps (das heute den Index nur aus den Props begrenzt).

C.3 Aktualisierung

  • Beim Einhängen des Widgets und bei jeder Änderung der Quellkonfiguration.
  • Periodisch, alle 60 Minuten (PROVIDER_LIST_REFRESH_MS) : ausreichend für ein Album, das sich bereichert, und dies holt den Übergang von Mitternacht der Erinnerungen in weniger als einer Stunde nach.
  • Bei Rückkehr zum Index 0 der Diashow, wenn die Liste älter als 60 min ist: ein Dashboard, das an einer Wand eingeschaltet bleibt, bleibt ohne zusätzliche Uhr aktuell.
  • Eine Aktualisierung, die eine kürzere Liste als der aktuelle Index zurückgibt, bringt den Index in die Grenzen zurück (bestehende Regel); eine identische Liste löst kein erneutes Laden von Bildern aus, der Cache erfüllt seinen Zweck.

C.4 Anzeigezustände

Situation Rendering
Leere Quelle (leeres Album, keine Erinnerungen heute) Expliziter Leerzustand, kein Fehler : „Keine Fotos in dieser Quelle heute.“ (dedizierter i18n-Schlüssel, unterschiedlich von emptyPhotos)
Nicht konfigurierter Anbieter Nachricht + Link zur Integrationskonfigurationsseite
Fehlender / gestoppter / unerreichbarer Anbieter Fehlermeldung mit dem Namen des Anbieters hervorgehoben — niemals ein Ausweichen auf einen anderen Anbieter
Fehlschlag eines einzelnen Bildes Aktuelles Verhalten: Fehlersymbol auf diesem Foto, die Diashow läuft weiter

F. Phase 2 — Externe Integrationen von type: "photo" (Design, nicht implementiert)

Direkte Übertragung von docs/specs/external-integrations.md §B.18 (Wetter) und §B.15 (Kommunikation). Nichts von dem, was in §A–E spezifiziert ist, ändert sich; die Implementierung dieser Phase wird external-integrations.md im selben Diff aktualisieren, wie es ihre Regel verlangt.

  • Manifest : type: "photo". Installationsbildschirm mit einer dedizierten Informationszeile („Diese Integration kann die Fotos bereitstellen, die auf Ihren Dashboards angezeigt werden“).
  • Proxy-Service : bietet photo.getSources / photo.getPhotos / photo.getImage an, weitergeleitet über WebSocket:
Befehl Nutzlast Ack (command-result) Verzögerung
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 roh, ohne data-URI-Präfix) 15 s

Die Verzögerung von 15 s ist die bereits akzeptierte für camera.get-image und weather.get (API-Aufruf eines Drittanbieters). Die Reihenfolge und die Obergrenze werden vom Kern angewendet: die Integration erhält limit als Hinweis (um nicht 5000 Einträge zu übertragen), der Kern schneidet nach der Normalisierung erneut.

  • Keine „Geräte“-Oberfläche : wie das Wetter und die Kommunikation hat eine Fotointegration weder einen Gerätebildschirm, noch Entdeckung, noch Zustände — nur Konfiguration / Überwachung / Protokolle.
  • Nichts Neues zur Validierung : normalizeSources / normalizePhotos / die Bildvalidierung von §B.6 sind so geschrieben, dass sie auf jeden Anbieter ab der v1 angewendet werden — der interne Immich-Dienst wird mit demselben Misstrauen behandelt wie eine Drittanbieterintegration, was garantiert, dass die Phase 2 keine Vertrauensfläche hinzufügt.