Mein Foto-Portfolio aktualisiert sich selbst: Lightroom API, Astro und Claude
Mein Portfolio unter lionstark.com lief jahrelang auf WordPress mit einem Premium-Theme und Elementor. Jedes neue Foto hieß: exportieren, hochladen, in die Galerie ziehen, Alt-Text tippen. Entsprechend selten habe ich es gemacht. Auf die Idee für den Umbau hat mich ein Tipp in r/photography gebracht. Dort hat jemand erzählt, dass seine Website bei jedem Rebuild die Fotos aus einem Lightroom-Album zieht. Genau das wollte ich auch.
Heute ist meine Seite eine statische Astro-Site. Die Source of Truth ist ein Album namens „Portfolio“ in Lightroom (Cloud). Was dort drin ist, steht auf der Website, in derselben Reihenfolge. Nehme ich ein Foto aus dem Album, verschwindet es auch von der Seite.
Der Workflow im Überblick
Auf meinem Server läuft alle 30 Minuten per Cron ein Sync-Script. Es macht der Reihe nach Folgendes:
- Access Token über den gespeicherten Refresh Token erneuern
- Das Album „Portfolio“ in der Reihenfolge lesen, die ich in Lightroom festgelegt habe
- Nur die Renditions herunterladen, die neu sind oder sich geändert haben
- Neue Fotos einmal von Claude taggen lassen
- Astro neu bauen und veröffentlichen
- Eine Push-Notification über ntfy schicken
Schritt 1: Projekt in der Adobe Developer Console
In der Adobe Developer Console legst du ein Projekt an, fügst die API „Lightroom Services“ hinzu und wählst als Credential-Typ „OAuth Web App“. Bei mir ist der erste Versuch mit „API configuration failed“ abgebrochen. Grund war das Redirect-URI-Pattern. Das ist ein Regex, und die Punkte müssen mit doppeltem Backslash escaped werden:
Redirect URI pattern: https://example\\.com/connect/.*
Default redirect URI: https://example.com/connect/
Die Default Redirect URI muss auf das Pattern passen. Achte auf den Slash am Ende, ohne ihn matcht /connect/.* nicht.

Schritt 2: Einmal verbinden, Refresh Token speichern
Für den OAuth-Login habe ich eine kleine Connect-Seite auf meinem Server gebaut, die ich genau einmal benutze. Client ID und Client Secret füge ich direkt in diese Seite ein, sie landen nirgendwo sonst, auch in keinem Repo. Die Seite leitet zu Adobe weiter, nimmt den Code am Redirect entgegen, tauscht ihn gegen Tokens und speichert den Refresh Token auf dem Server. Ab dann holt sich das Sync-Script seine Access Tokens selbst.
Schritt 3: Die API-Pitfalls
Antworten beginnen mit while(1){}
Die Lightroom API stellt jeder JSON-Antwort while(1){} voran, als Schutz gegen JSON-Hijacking. JSON.parse scheitert daran. Das Präfix muss also vorher weg:
const text = await res.text();
const data = JSON.parse(text.replace(/^while\s*\(1\)\s*\{\s*\}\s*/, ""));
Mein Filter fand 0 Alben
In der Doku ist bei Alben vom Subtype project die Rede. Danach habe ich gefiltert und null Alben bekommen. In Lightroom (Cloud) haben Alben den Subtype collection, Ordner sind collection_set. Mit dem richtigen Filter war „Portfolio“ sofort da:
const album = data.resources.find(
(a) => a.subtype === "collection" && a.payload?.name === "Portfolio"
);
Die Rendition hat keine EXIF-Daten
Ich lade die 2048-px-Rendition. Die enthält schon meine Bearbeitung, ich muss also nichts extra exportieren. EXIF-Daten sind darin aber keine. Kamera, Objektiv und Einstellungen hole ich deshalb aus den Asset-Metadaten unter xmp: das Kameramodell aus tiff (Model), das Objektiv aus aux (Lens) und die Belichtungswerte aus exif. Die Blende kommt dort als Bruch, zum Beispiel [28,10], und muss umgerechnet werden:
const frac = (v) => (Array.isArray(v) ? v[0] / v[1] : v);
const aperture = frac(xmp.exif?.FNumber); // [28,10] -> 2.8
Schritt 4: Auto-Tagging mit Claude
Früher habe ich Kategorien und Alt-Texte von Hand gepflegt, und meistens fehlten sie. Jetzt geht jedes neue Foto einmal an Claude Haiku, vorher auf 800 px verkleinert. Zurück kommen 1 bis 2 Kategorien, ein englischer Alt-Text und ein Ort, falls der klar erkennbar ist. Das Ergebnis wird pro Fotoversion gecacht. Ändere ich die Bearbeitung in Lightroom nicht, wird das Foto auch nicht noch einmal geschickt. Pro Foto kostet das ungefähr 0,1 Cent.
Ein Fehler hat mich eine Weile beschäftigt. Bei manchen Fotos kam vom Modell einfach nichts zurück. Es hatte sein Token-Budget komplett fürs Reasoning verbraucht, bevor es eine Antwort geschrieben hat. Seit Thinking für diesen Call deaktiviert ist und max_tokens höher steht, kommt bei jedem Foto ein Ergebnis.
Die Website selbst
Optisch sollte sich wenig ändern. Die Galerie ist wieder dunkel und im Justified-Layout wie auf der alten Seite. Dazu kamen ein paar Features, die ich mit Elementor nie hinbekommen habe:
- Die Lightbox öffnet sich mit einem Zoom aus dem Thumbnail heraus.
- Ein „Shot on“-Panel zeigt Kamera, Objektiv und Einstellungen aus den EXIF-Daten.
- Auf dem Phone blendest du das Panel mit einem Tap aus und wischst zwischen den Fotos.
FE 24-70mm F2.8 GM II*, 24mm, f/2.8, 1/40s, ISO 800″ class=“wp-image-291″/>Im Panel stehen bei mir meistens die Sony α7C R* und eins von zwei Objektiven, das FE 24-70mm F2.8 GM II oder das FE 70-200mm F2.8 GM OSS II*.
Der Cron-Job, der nie lief
Von Hand lief das Sync-Script sauber durch. Im Cron passierte trotzdem nichts, und eine Fehlermeldung gab es auch keine. Das Script startet bei mir über runuser, damit es nicht als root läuft. Cron hat aber einen sehr kurzen PATH, in dem /usr/sbin fehlt. runuser wurde also nie gefunden. Mit dem vollen Pfad läuft es:
*/30 * * * * /usr/sbin/runuser -u portfolio -- /opt/portfolio/sync.sh
Seitdem kommt nach jedem Durchlauf mit Änderungen eine ntfy-Push-Notification. Wie ich ntfy selbst hoste, steht im Artikel zu Uptime Kuma und ntfy.

Fazit
Mein Workflow ist jetzt: Foto in Lightroom bearbeiten, ins Album „Portfolio“ ziehen, fertig. Spätestens nach 30 Minuten ist es auf lionstark.com, mit Kategorie, Alt-Text und Kameradaten.
Fragen zur Lightroom API oder ein ähnliches Setup mit einem anderen Tool? Schreib es in die Kommentare.
* Affiliate-Link: Als Amazon-Partner verdiene ich an qualifizierten Verkäufen. Für dich ändert sich am Preis nichts. Mehr dazu