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):
| Feld | Typ | Beschreibung |
|---|---|---|
social_profile_id | FK | Zuordnung zum Profil |
path | string | Original-Datei auf R2 |
path_large | string? | 512×512 WebP-Variante |
path_medium | string? | 256×256 WebP-Variante |
path_thumbnail | string? | 128×128 WebP-Variante |
hash | string(64) | SHA256 Hash der Original-Bytes |
source_url | text? | Externe URL von der Plattform |
blurhash | string? | 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:
- URL-Check — Wenn die Plattform-URL unverändert ist, wird nicht einmal heruntergeladen (häufigster Fall)
- Hash-Check — Wenn URL anders, aber Bytes identisch (z.B. CDN-Umzug), wird nicht gespeichert
R2-Operationen pro Szenario
| Szenario | R2 Ops | Beschreibung |
|---|---|---|
| URL identisch (Normalfall) | 0 | Häufigstes Szenario, ~95% aller Aufrufe |
| URL anders, Hash identisch | 0 | CDN-URL-Wechsel ohne Bildänderung |
| Neues Bild (erstes) | 4 PUTs | Original + 3 Varianten |
| Neues Bild (Wechsel) | 4 PUTs + 3 DELETEs | + alte Varianten löschen |
Lightbox-History (Watcher-Detail)
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
| Variante | Profilbilder | Video-Thumbnails |
|---|---|---|
| Large | 512×512 px | 960×540 px (16:9) |
| Medium | 256×256 px | 640×360 px |
| Thumbnail | 128×128 px | 320×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):
| Feld | Beschreibung |
|---|---|
thumbnail_url | Externe YouTube-URL |
thumbnail_path | Original auf R2 |
thumbnail_path_large | 960×540 WebP |
thumbnail_path_medium | 640×360 WebP |
thumbnail_path_thumbnail | 320×180 WebP |
thumbnail_hash | SHA256 Hash |
thumbnail_blurhash | LQIP Placeholder |
Kein History — nur das aktuelle Thumbnail pro Video. Gleiche Deduplizierung (URL + Hash).
Commands
| Command | Beschreibung | Schedule |
|---|---|---|
images:refresh-missing | Fehlende Profilbilder von Plattform-APIs auffrischen | Taeglich 05:00 UTC |
images:cleanup-profiles | Duplikate entfernen, alte Varianten bereinigen | Manuell |
images:cleanup-orphaned | Verwaiste Varianten-Dateien auf R2 ohne DB-Record | Manuell |
images:generate-variants | Varianten-Backfill für Bilder ohne Varianten | Täglich 04:30 UTC + Manuell |
images:cleanup-profiles
Bereinigt Profilbilder-Storage systematisch:
- Duplikate entfernen: Gleicher Hash pro Profil → nur neuestes behalten, Rest komplett löschen (Record + R2-Dateien)
- Varianten alter Bilder entfernen: Ältere Bilder mit anderem Hash → Original behalten (History), Varianten löschen
- 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
| Service | Beschreibung |
|---|---|
ProfileImageUpdater | Profilbilder herunterladen, deduplizieren, speichern |
VideoThumbnailUpdater | Video-Thumbnails herunterladen, deduplizieren, speichern |
ProfileImageProcessor | Varianten 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:
- Lokales Bild →
latestImageRelation → optimierte Variante (WebP) - Externe URL →
thumbnail_urlvom Profil (Plattform-CDN) - Placeholder →
assets/no-picture.jpg
Proaktive R2-Verifizierung (Watcher-Detail)
Auf der Watcher-Detailseite prueft ensureProfileImageStored() proaktiv ob die gespeicherte Bilddatei in R2 tatsaechlich existiert:
- Kein Bild in DB → Bild erstmalig von Platform-API holen (YouTube/Instagram)
- Bilder in DB vorhanden →
Storage::exists()pruefen ob Datei in R2 existiert - Datei fehlt → Stale DB-Records loeschen + sofort neu von Platform-API holen
- Rate-Limit →
exists()-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
| Box | Beschreibung |
|---|---|
| Mit Bild | Profile 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) |
| BlurHash | Profile mit LQIP Placeholder (+ fehlende Anzahl) |
| Alle Varianten | Profile mit allen 4 Varianten komplett |
| Ohne Bild | Profile ohne jegliches gespeichertes Bild |
| Verdaechtige Bilder | Bilder unter 10 KB (moeglicherweise defekt/Placeholder) |
| Orphan-Profile | Profile 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:
RefreshMissingProfileImagesJob wird dispatcht- Basis-Query: Profile ohne lokales Bild (
whereDoesntHave('images')), mittracking_enabled=true - YouTube:
YouTubeDataApiClient::getChannelsByIds()in Batches von 50, Quota-Guard-Check vor jedem Batch - Instagram:
CollectorJobDispatcher::dispatchBatch()mitjob_type='daily_scrape' - Fortschritt in Cache (
image-refresh-progress), Polling alle 30s - 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:
- Button dispatcht
GenerateMissingImageVariantsJob auf die Queue - Job verarbeitet Bilder in Chunks von 50 (Memory-Limit 128MB)
- Fortschritt wird in Cache gespeichert und per Polling (30s) angezeigt
- Nach Abschluss: Reverb-Event
image.variants.completedauf demadmin-Channel - 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
- Bestaetigen:
PUBLIC_STORAGE_DRIVER=s3in Production.env - Stichprobe: 10 zufaellige Profile in der App aufrufen — Bilder muessen von R2 laden
- Backup:
tar -czf /backup/social_profiles_local_$(date +%Y%m%d).tar.gz storage/app/public/social_profiles/ - Loeschen:
rm -rf storage/app/public/social_profiles/ - Symlink pruefen:
php artisan storage:link— sollte keine Auswirkung haben da public disk = R2
Was NICHT geloescht werden darf
| Pfad | Loeschbar? | Begruendung |
|---|---|---|
storage/app/public/social_profiles/ | Ja (nach Backup) | Alle Bilder auf R2, DB-Pfade zeigen auf R2 |
storage/app/public/ (andere Dateien) | Pruefen | Andere Features koennten lokale Dateien nutzen |
storage/framework/cache/ | Nein | Laravel Cache-Dateien |
storage/logs/ | Alte Logs ja | Aktuelle Logs behalten |