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) undEditPhotoBox.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 vonserver/models/dashboard.js(photos: Array von{ url, caption }, max 100,photo_fit,photo_slideshow_interval0–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 überresizeImageBuffer, und gibt die Zeichenkette"image/jpeg;base64,…"zurück, die das Frontend insrc={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:
- Authentifizierung. Immich erfordert einen Header
x-api-keyfür jede Anfrage. Der aktuelle Proxy macht ein nacktesGET, ohne Header: Er kann strukturell nicht mit Immich kommunizieren. - 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. - 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, diephoto.getSources(...)/photo.getPhotos(...)/photo.getImage(...)exponieren; - Immich ist ein Anbieter unter vielen, implementiert in v1 als interner Dienst (
server/services/immich), genau wieopenweatherfü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-Typedes Dritten), Größe ≤ 25 MB (abgestimmt aufMAX_SOURCE_IMAGE_BYTES), dann systematische Re-Kodierung durchresizeImageBufferin 800×400 JPEG q80. Ein Anbieter kann daher kein SVG, kein HTML oder eine 200 MB-Datei über die Gladys-Quelle bereitstellen; photo_idvom Kern vor jedem Anbieteraufruf erneut überprüft: Ein außerhalb der Regex liegender Identifikator gibt404zurü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:
- Dropdown-Menü Anbieter ←
GET /api/v1/photo/provider. Wenn die Liste leer ist: Hilfeblock „Keine Fotointegration ist installiert“ mit einem Link zum Integrationskatalog. - Dropdown-Menü Quelle ←
GET /api/v1/photo/source?service=…, gruppiert nachtype(: *Alben*, *Souvenirs*), Bezeichnunglabel+count, wenn vorhanden. Schreibtphoto_source_type**und**photo_source_id` in einer einzigen Aktion. - Wählen Sie Reihenfolge (neueste zuerst / älteste zuerst / zufällig), Feld Maximale Anzahl von Fotos (1–100, Standard 50), Schalter Automatische Beschriftungen.
- Vorschau : die ersten 3 aufgelösten Fotos, geladen über
GET /api/v1/photo/image— dieselbe Rolle wie derPhotoPreviewim manuellen Modus (überprüfen, bevor gespeichert wird), ohne die Logik des Debouncings zu duplizieren, da es keine Eingabe Zeichen für Zeichen mehr gibt.
- Dropdown-Menü Anbieter ←
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:photoskommt 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 überGET /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.getImagean, 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.