Zum Hauptinhalt springen

Profilbilder & Thumbnails

Profilbilder und Video-Thumbnails werden von den jeweiligen Plattformen heruntergeladen, lokal optimiert und auf Cloudflare R2 gespeichert. Ziel: minimale R2-Requests, effiziente Darstellung mit Varianten und Deduplizierung.


Profilbilder (Instagram + YouTube)

Datenmodell

Profilbilder werden in der Tabelle social_profile_images gespeichert (1 Row pro Bild):

FeldTypBeschreibung
social_profile_idFKZuordnung zum Profil
pathstringOriginal-Datei auf R2
path_largestring?512×512 WebP-Variante
path_mediumstring?256×256 WebP-Variante
path_thumbnailstring?128×128 WebP-Variante
hashstring(64)SHA256 Hash der Original-Bytes
source_urltext?Externe URL von der Plattform
blurhashstring?Base64 LQIP Placeholder (4×4 WebP)

Täglicher Ablauf (Scraping)

Plattform-API liefert neue thumbnail_url


URL identisch? ──yes──▶ Skip (0 R2 Ops)
│no

Bild herunterladen (HTTP GET)


SHA256 Hash berechnen


Hash identisch? ──yes──▶ Skip (0 R2 Ops)
│no

Neues Bild erkannt!

├─▶ Original auf R2 speichern (PUT)
├─▶ 3 Varianten generieren (3× PUT)
├─▶ Alte Varianten löschen (3× DELETE)
└─▶ DB-Record anlegen

Zweistufige Deduplizierung:

  1. URL-Check — Wenn die Plattform-URL unverändert ist, wird nicht einmal heruntergeladen (häufigster Fall)
  2. Hash-Check — Wenn URL anders, aber Bytes identisch (z.B. CDN-Umzug), wird nicht gespeichert

R2-Operationen pro Szenario

SzenarioR2 OpsBeschreibung
URL identisch (Normalfall)0Häufigstes Szenario, ~95% aller Aufrufe
URL anders, Hash identisch0CDN-URL-Wechsel ohne Bildänderung
Neues Bild (erstes)4 PUTsOriginal + 3 Varianten
Neues Bild (Wechsel)4 PUTs + 3 DELETEs+ alte Varianten löschen

Wenn ein Profil sein Bild ändert (anderer Hash), wird das alte Original behalten für die Lightbox-History:

  • Nur das neueste Bild hat Varianten (optimierte WebP-Versionen)
  • Ältere Bilder behalten nur das Original (für Lightbox-Navigation)
  • Duplikate (gleicher Hash) werden beim Cleanup gelöscht

Varianten-Größen

VarianteProfilbilderVideo-Thumbnails
Large512×512 px960×540 px (16:9)
Medium256×256 px640×360 px
Thumbnail128×128 px320×180 px

Format: WebP (Standard) oder AVIF. Qualität: 80 (konfigurierbar via POSTBOX_IMAGE_QUALITY).


Video-Thumbnails (YouTube)

Video-Thumbnails werden direkt auf dem Video-Model gespeichert (keine separate Tabelle):

FeldBeschreibung
thumbnail_urlExterne YouTube-URL
thumbnail_pathOriginal auf R2
thumbnail_path_large960×540 WebP
thumbnail_path_medium640×360 WebP
thumbnail_path_thumbnail320×180 WebP
thumbnail_hashSHA256 Hash
thumbnail_blurhashLQIP Placeholder

Kein History — nur das aktuelle Thumbnail pro Video. Gleiche Deduplizierung (URL + Hash).


Commands

CommandBeschreibungSchedule
images:refresh-missingFehlende Profilbilder von Plattform-APIs auffrischenTaeglich 05:00 UTC
images:cleanup-profilesDuplikate entfernen, alte Varianten bereinigenManuell
images:cleanup-orphanedVerwaiste Varianten-Dateien auf R2 ohne DB-RecordManuell
images:generate-variantsVarianten-Backfill für Bilder ohne VariantenTäglich 04:30 UTC + Manuell

images:cleanup-profiles

Bereinigt Profilbilder-Storage systematisch:

  1. Duplikate entfernen: Gleicher Hash pro Profil → nur neuestes behalten, Rest komplett löschen (Record + R2-Dateien)
  2. Varianten alter Bilder entfernen: Ältere Bilder mit anderem Hash → Original behalten (History), Varianten löschen
  3. R2-Dateien bereinigen: Gelöschte Records → zugehörige Dateien auf R2 löschen
# Dry-Run (zeigt was gelöscht würde)
php artisan images:cleanup-profiles --dry-run

# Ausführen
php artisan images:cleanup-profiles

# Nur ein bestimmtes Profil
php artisan images:cleanup-profiles --profile-id=123

images:cleanup-orphaned

Findet und entfernt verwaiste Varianten-Dateien auf R2 (WebP/AVIF-Dateien ohne zugehörigen DB-Record).

php artisan images:cleanup-orphaned --dry-run
php artisan images:cleanup-orphaned --videos # auch Video-Thumbnails prüfen

Services

ServiceBeschreibung
ProfileImageUpdaterProfilbilder herunterladen, deduplizieren, speichern
VideoThumbnailUpdaterVideo-Thumbnails herunterladen, deduplizieren, speichern
ProfileImageProcessorVarianten generieren (WebP/AVIF), BlurHash, Varianten löschen

Konfiguration

// config/postbox.php → 'images'
'driver' => env('POSTBOX_IMAGE_DRIVER', 'gd'), // 'gd' oder 'imagick'
'quality' => env('POSTBOX_IMAGE_QUALITY', 80), // WebP-Qualität (0-100)
'format' => env('POSTBOX_IMAGE_FORMAT', 'webp'), // 'webp' oder 'avif'

Fallback-Kette (Anzeige)

Wenn ein Profil auf der UI angezeigt wird:

  1. Lokales BildlatestImage Relation → optimierte Variante (WebP)
  2. Externe URLthumbnail_url vom Profil (Plattform-CDN)
  3. Placeholderassets/no-picture.jpg

Proaktive R2-Verifizierung (Watcher-Detail)

Auf der Watcher-Detailseite prueft ensureProfileImageStored() proaktiv ob die gespeicherte Bilddatei in R2 tatsaechlich existiert:

  1. Kein Bild in DB → Bild erstmalig von Platform-API holen (YouTube/Instagram)
  2. Bilder in DB vorhandenStorage::exists() pruefen ob Datei in R2 existiert
  3. Datei fehlt → Stale DB-Records loeschen + sofort neu von Platform-API holen
  4. Rate-Limitexists()-Check nur alle 10 Minuten pro Profil (Cache-Key: profile-image-verified:{profile_id})

Hintergrund: Der urspruengliche Browser-Error-basierte Mechanismus (refreshProfileImage() via x-on:error) funktionierte nicht, weil der error-Event nicht aus dem Shadow DOM der flux:lightbox-Komponente bubblet. Die proaktive Verifizierung ersetzt diesen Ansatz.

Wichtig: Empty-String-Handling

Die DB-Felder path_large, path_medium, path_thumbnail koennen leere Strings '' statt null enthalten (wenn Image-Processing fehlschlaegt oder unterbrochen wird). SocialProfileImage::getUrlForSize() und RecommendedProfiles::resolveImageUrl() verwenden daher filled() statt ?? (null coalescing) — ?? faengt nur null ab, nicht ''.

CORS-Retry bei externen Bildern

Das <x-profile-image> Component setzt crossorigin="anonymous" fuer Canvas-Kompatibilitaet. Falls ein externes Bild (z.B. YouTube-Thumbnail) wegen fehlender CORS-Header fehlschlaegt, wird automatisch ohne crossorigin retried bevor zum Placeholder gewechselt wird.


Admin: Profilbilder & Varianten (Social Profiles)

Die Admin-Seite /admin/social-profiles zeigt eine erweiterte Profilbilder-Sektion am Seitenende:

Statistik-Boxen

BoxBeschreibung
Mit BildProfile mit mindestens einem gespeicherten Bild (+ Prozentanteil)
Thumbnail (sm)Profile mit 128x128 Variante (+ fehlende Anzahl)
Medium (md)Profile mit 256x256 Variante (+ fehlende Anzahl)
Large (lg)Profile mit 512x512 Variante (+ fehlende Anzahl)
BlurHashProfile mit LQIP Placeholder (+ fehlende Anzahl)
Alle VariantenProfile mit allen 4 Varianten komplett
Ohne BildProfile ohne jegliches gespeichertes Bild
Verdaechtige BilderBilder unter 10 KB (moeglicherweise defekt/Placeholder)
Orphan-ProfileProfile mit externer URL aber ohne lokales Bild

Profilbilder auffrischen (Admin-UI + Scheduled)

Ueber den Button "Bilder auffrischen" bei den Stat-Boxen "Ohne Bild" und "Orphan-Profile" oder automatisch taeglich um 05:00 UTC werden fehlende Profilbilder von den Plattform-APIs geholt:

YouTube: Batch-API-Calls (channels.list, max 50 IDs pro Request) holen frische Thumbnail-URLs. Die besten Thumbnails werden ausgewaehlt (maxres > standard > high > medium > default), heruntergeladen und lokal gespeichert.

Instagram: Profile werden als Collector-Jobs fuer einen vollstaendigen Scrape eingeplant (nicht nur Bilder, sondern alle Profildaten).

Ablauf:

  1. RefreshMissingProfileImages Job wird dispatcht
  2. Basis-Query: Profile ohne lokales Bild (whereDoesntHave('images')), mit tracking_enabled=true
  3. YouTube: YouTubeDataApiClient::getChannelsByIds() in Batches von 50, Quota-Guard-Check vor jedem Batch
  4. Instagram: CollectorJobDispatcher::dispatchBatch() mit job_type='daily_scrape'
  5. Fortschritt in Cache (image-refresh-progress), Polling alle 30s
  6. Nach Abschluss: Reverb-Event + Admin-Notification

Monatlicher Retry: Profile die beim letzten Versuch fehlgeschlagen sind (image_fetch_failed_at), werden erst nach 30 Tagen erneut versucht. Verhindert endlose Retry-Loops fuer dauerhaft fehlende Profile.

YouTube Quota Guard: Wenn weniger als 10% der API-Quota frei ist, werden YouTube-Profile uebersprungen und am naechsten Tag erneut versucht.

Monitoring: Pipeline-Step image_refresh auf /admin/update-status, Heartbeat image_refresh_missing.

# Manuell ausfuehren
php artisan images:refresh-missing

# Dry-Run (zeigt was verarbeitet wuerde)
php artisan images:refresh-missing --dry-run

# Nur eine Plattform
php artisan images:refresh-missing --platform=youtube
php artisan images:refresh-missing --platform=instagram

Batch-Generierung (Admin-UI)

Ueber den Button "Fehlende generieren" in der Profilbilder-Sektion koennen alle fehlenden Varianten in einem Batch-Job generiert werden:

  1. Button dispatcht GenerateMissingImageVariants Job auf die Queue
  2. Job verarbeitet Bilder in Chunks von 50 (Memory-Limit 128MB)
  3. Fortschritt wird in Cache gespeichert und per Polling (30s) angezeigt
  4. Nach Abschluss: Reverb-Event image.variants.completed auf dem admin-Channel
  5. Livewire-Component empfaengt Event, zeigt Toast und aktualisiert Statistiken

BlurHash-Preview in Profil-Tabelle

Jede Zeile in der Profil-Tabelle zeigt eine kleine BlurHash-Vorschau (7x7 Pixel, gerundet) neben dem Handle. Hilft bei der visuellen Qualitaetskontrolle direkt in der Liste.

Automatischer Varianten-Backfill (E5)

images:generate-variants laeuft taeglich um 04:30 UTC automatisch und generiert fehlende Varianten fuer Bilder, bei denen die Generierung waehrend des Uploads fehlschlug.


Lokaler Storage nach R2-Migration

Nach der vollstaendigen Migration auf Cloudflare R2 kann der lokale Bildbestand geloescht werden.

Was liegt lokal?

storage/app/public/social_profiles/ enthaelt den gesamten alten Bildbestand:

  • Originale: {profile_id}/{hash}.jpg/png/webp
  • Varianten: {profile_id}/{hash}_lg.webp, _md.webp, _sm.webp

Sicherheits-Checkliste vor Loeschung

  1. Bestaetigen: PUBLIC_STORAGE_DRIVER=s3 in Production .env
  2. Stichprobe: 10 zufaellige Profile in der App aufrufen — Bilder muessen von R2 laden
  3. Backup: tar -czf /backup/social_profiles_local_$(date +%Y%m%d).tar.gz storage/app/public/social_profiles/
  4. Loeschen: rm -rf storage/app/public/social_profiles/
  5. Symlink pruefen: php artisan storage:link — sollte keine Auswirkung haben da public disk = R2

Was NICHT geloescht werden darf

PfadLoeschbar?Begruendung
storage/app/public/social_profiles/Ja (nach Backup)Alle Bilder auf R2, DB-Pfade zeigen auf R2
storage/app/public/ (andere Dateien)PruefenAndere Features koennten lokale Dateien nutzen
storage/framework/cache/NeinLaravel Cache-Dateien
storage/logs/Alte Logs jaAktuelle Logs behalten