Profilbilder & Thumbnails
Profilbilder und Video-Thumbnails werden von den jeweiligen Plattformen heruntergeladen, lokal optimiert und im lokalen Storage (storage/app/public/, 2-Level-sharded — siehe Local-Filesystem-Sharding) gespeichert. Ziel: minimale Downloads/Schreibvorgänge, effiziente Darstellung mit Varianten und Deduplizierung.
Storage-Backend: Production läuft seit Plan 84 (2026-05-28) auf
PUBLIC_STORAGE_DRIVER=local. Die frühere R2/RustFS-Ablage ist inaktiv (siehe Object Storage – Archiv).
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 im Storage (sharded Pfad, z. B. social_profiles/567/234/1234567/{hash}.jpg) |
hash | string(64) | SHA256 Hash der Original-Bytes |
source_url | text? | Externe URL von der Plattform |
blurhash | string? | Base64 LQIP Placeholder (4×4 WebP) |
Variant-Pfade nicht mehr in der DB: Die Spalten
path_large/path_medium/path_thumbnailwurden mit Plan 84 Phase 7 gedroppt. Die Varianten (_lg.webp/_md.webp/_sm.webp) liegen im selben sharded Verzeichnis wie das Original und werden zur Laufzeit auspathviaSocialProfileImage::variantPath($size)abgeleitet (kein DB-Lookup, kein Disk-Check beim Rendern).
Täglicher Ablauf (Scraping)
Plattform-API liefert neue thumbnail_url
│
▼
URL identisch? ──yes──▶ Skip (0 Schreibvorgänge)
│no
▼
Bild herunterladen (HTTP GET)
│
▼
SHA256 Hash berechnen
│
▼
Hash identisch? ──yes──▶ Skip (0 Schreibvorgänge)
│no
▼
Neues Bild erkannt!
│
├─▶ Original im Storage speichern (sharded Pfad)
├─▶ 3 Varianten generieren + speichern (_lg/_md/_sm.webp)
├─▶ Alte Varianten löschen
└─▶ 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
Storage-/Download-Operationen pro Szenario
Die Dedup spart Downloads und Schreibvorgänge — das war in der R2-Ära API-Kosten-relevant und gilt genauso für den lokalen Storage (Bandbreite + Datei-IO):
| Szenario | Datei-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 Writes | Original + 3 Varianten |
| Neues Bild (Wechsel) | 4 Writes + 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 im Storage (sharded, youtube_videos/{ab}/{cd}/{video_id}/{hash}.jpg) |
thumbnail_hash | SHA256 Hash |
thumbnail_blurhash | LQIP Placeholder |
Auch hier wurden die Variant-Spalten
thumbnail_path_large/medium/thumbnailmit Plan 84 Phase 7 gedroppt — Variant-Pfade werden viaYouTubeVideo::variantThumbnailPath($size)austhumbnail_pathabgeleitet.
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 im Storage 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 + Storage-Dateien)
- Varianten alter Bilder entfernen: Ältere Bilder mit anderem Hash → Original behalten (History), Varianten löschen
- Storage-Dateien bereinigen: Gelöschte Records → zugehörige Dateien im Storage 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 im Storage (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 (sharded via ShardedPathBuilder) |
VideoThumbnailUpdater | Video-Thumbnails herunterladen, deduplizieren, speichern (sharded) |
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:
- Optimierte Variante →
latestImage/imagesRelation → WebP-Variante (_lg/_md/_sm) viagetDisplayImageUrl($size) - Original → das gespeicherte Originalbild, falls die Variante (noch) nicht auf der Platte liegt
- Placeholder →
assets/no-picture.jpg
Es gibt keine Stufe „externe URL" mehr — externe Plattform-CDN-Bilder werden grundsätzlich nicht ausgeliefert.
Variante → Original → Platzhalter (client-seitig)
Varianten werden best-effort generiert (ProfileImageProcessor::generateVariants läuft im try/catch, plus Backfill images:generate-variants). In der Lücke kann das Original auf der Platte liegen, die abgeleiteten Variant-Pfade (…_lg.webp) aber noch fehlen — getUrlForSize() leitet die Variant-URL rein aus dem Original-Pfad ab und prüft die Existenz bewusst nicht (kein Disk-IO pro Render). Damit ein fehlendes Variant-File nicht direkt zum Platzhalter führt (obwohl das echte Bild als Original existiert), implementiert die <x-profile-image>-Komponente eine client-seitige Kette: die Variante ist src, das Original steht als data-fallback-src bereit, und der onerror-Handler lädt bei einem 404 zuerst das Original und erst dann den Platzhalter. So zeigen Grids/Listen das echte Bild, solange Varianten nachgeneriert werden, statt vorschnell auf den Platzhalter zu fallen.
Watcher-Detail-Lightbox: flux:lightbox.image (publiziert unter resources/views/flux/lightbox/) zeigt im Thumbnail ebenfalls die optimierte Variante; das Original wird als fallback-src mitgegeben und dient (a) als onerror-Fallback des Thumbnails und (b) als Quelle der Vollbild-Zoom-Ansicht (höchste Qualität, garantiert vorhanden). So bleibt der Avatar auf dem einheitlichen Format und kippt nie auf den Platzhalter, falls eine Variante noch generiert wird. Das war die Ursache, dass ein Profil im Discover sein Bild zeigte, auf der Watcher-Detailseite aber den Platzhalter.
Hotlink-Schutz: keine externen Bilder, strukturell (kein Schalter)
Die Display-Accessoren (SocialProfile::getDisplayImageUrl(), PublicExplorerProfile::getDisplayImageUrl(), YouTubeVideo::getDisplayThumbnailUrl(), ExploreTrendingVideo::getDisplayThumbnailUrl()) liefern ausschließlich lokale Storage-URLs oder den Platzhalter — nie eine externe Plattform-CDN-URL. Es gibt bewusst keinen Config-Schalter mehr, der das umschalten könnte (das frühere serve_external_fallback wurde ersatzlos entfernt). Gründe:
- Datenschutz: Externe URLs (
yt3.ggpht.com,*.cdninstagram.com,i.ytimg.com) würden die IP jedes Seitenbesuchers an Google/Meta leaken (Hotlinking). - Tote Bilder: Plattform-CDN-URLs (z.B.
…=s800-c-k-…) laufen ab → kaputte<img>.
hasRealImage() liefert nur true, wenn ein lokales Bild vorliegt — eine reine externe thumbnail_url zählt nicht (Lightbox/Zoom bleiben aus, bis das Bild lokal ist).
Vorab berechnete Bild-URLs: Einige Komponenten berechnen die Bild-URL nicht über den Model-Accessor, sondern selbst (für gecachte Slider-/Leaderboard-Rows) und reichen sie als :url an <x-profile-image> weiter. Sie liefern die gleiche optimierte Variante wie der kanonische Pfad: RecommendedProfiles::resolveImageUrl (Dashboard-Empfehlungen, large) und HasSnapshotData::resolveProfileImageUrls (Tops & Flops + Dashboard-Leaderboards, medium). Der :profile-Pfad von <x-profile-image> ist über getDisplayImageUrl() ohnehin abgesichert.
Ein Format überall (webp/avif), Größe je Ort: Für die Anzeige wird immer die optimierte Variante genutzt — nur die Größe variiert je Kontext (thumb 128 / medium 256 / large 512). Das jpg/png/heic-Original ist ausschließlich Quelle (Variantengenerierung) und Archiv (Lightbox-History), nie die primäre Anzeige-URL. Discover und der Admin-AI-Enhancer servierten früher das Original — sie laufen jetzt über den kanonischen Variant-Pfad (Discover via explore.profile-card → getDisplayImageUrl('large'), AI-Enhancer via <x-profile-image :profile>); die früheren getProfileImageUrl-Methoden wurden entfernt.
Denormalisierte Explorer-Spalte: public_explorer_profiles.thumbnail_url wird vom Explorer-Build nur noch mit lokalen Storage-URLs (oder null) befüllt. PublicExplorerProfile::getDisplayImageUrl() liefert den Spaltenwert nur aus, wenn er per Allowlist auf unseren public-Disk zeigt — veraltete Zeilen mit externer URL fallen so automatisch auf den Platzhalter zurück.
Die externe thumbnail_url bleibt auf dem social_profiles-Datensatz erhalten — sie ist der Anker für den Re-Fetch (images:refresh-missing holt die frische URL von der API und legt das Bild lokal ab). Sie wird nur nie mehr angezeigt.
Proaktive Storage-Verifizierung (Watcher-Detail)
Auf der Watcher-Detailseite prueft ensureProfileImageStored() proaktiv ob die gespeicherte Bilddatei auf dem public-Disk tatsaechlich existiert:
- Kein Bild in DB → Bild erstmalig von Platform-API holen (YouTube/Instagram)
- Bilder in DB vorhanden →
Storage::disk('public')->exists()pruefen ob die Datei 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
Das Feld path (Original) kann in Altbeständen einen leeren String '' statt null enthalten (wenn Image-Processing fehlschlug oder unterbrochen wurde). SocialProfileImage::getUrlForSize() und RecommendedProfiles::resolveImageUrl() verwenden daher filled() statt ?? (null coalescing) — ?? fängt nur null ab, nicht ''. (Die früheren Variant-Spalten path_large/medium/thumbnail wurden mit Plan 84 Phase 7 gedroppt; Varianten werden via variantPath() aus path abgeleitet.)
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
Pause-Schutz: Während storage:reset-images läuft (oder ein Reset im Gange ist), pausiert das Cache-Flag images:refetch-paused (TTL 2h) den Re-Fetch — images:refresh-missing überspringt dann bewusst und setzt nur einen Heartbeat. Bleibt das Flag nach einem abgebrochenen Reset stehen, kommt kein Bild zurück, bis es gelöscht wird (Cache::forget('images:refetch-paused')).
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
# Force-Mode: ignoriert den 30-Tage-Cooldown auf image_fetch_failed_at
# Sinnvoll z.B. nach einer Storage-Migration oder einem storage:reset-images-Lauf,
# wenn ein Schwung Profile irrtuemlich als failed markiert wurde
php artisan images:refresh-missing --force --dry-run # Zahlen pruefen
php artisan images:refresh-missing --force # Backlog abarbeiten
--force Flag: Umgeht die image_fetch_failed_at-Cooldown-Logik und prozessiert ALLE Profile mit tracking_enabled=true ohne images()-Relation. Nutze das nach einem Vorfall, der einen Schwung Image-Downloads abgewuergt hat (Storage-Migration, Quota-Recovery, Plattform-API-Outage). Im normalen Betrieb wird der Daily-Schedule weiterhin den Cooldown respektieren — Quota-Schutz bleibt unveraendert.
Achtung — greift nur bei Profilen OHNE Bild-Row: Die Basis-Query ist
whereDoesntHave('images'). Profile, die eine kaputte Bild-Row haben (Pfad zeigt ins Leere), werden vonimages:refresh-missingnicht angefasst. Solche Rows zuerst mitstorage:shard-verifygegen den Storage abgleichen (leert fehlende Pfade), bevor der Re-Fetch greifen kann.
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.
Storage-Reset (Bilder wegwerfen + neu scrapen)
Production läuft auf lokalem Storage (PUBLIC_STORAGE_DRIVER=local); die Files liegen sharded unter storage/app/public/social_profiles/{b1}/{b2}/{id}/…. Es gibt kein R2/RustFS mehr im aktiven Pfad (siehe Object Storage – Archiv).
Statt einen großen Altbestand zu migrieren, wird er bei Bedarf verworfen und frisch (bereits sharded) neu gescraped — auf dem CIFS/SMB-Mount ist das drastisch schneller als ein file-weises Storage::move():
# Alle Profil-/Video-/OG-Bilder zuruecksetzen (DB-Felder leeren + Storage-Ordner killen)
php artisan storage:reset-images --dry-run # erst zeigen, was passiert
php artisan storage:reset-images # ausfuehren (mit Sicherheitsabfrage)
php artisan storage:reset-images --only=profiles # nur Profilbilder
Der Command pausiert dabei kurz images:refresh-missing (Cache-Flag images:refetch-paused, TTL 2h), leert die DB-Felder (social_profile_images truncate; youtube_videos.thumbnail_*; public_explorer_profiles.og_image_*) und schiebt die Top-Level-Ordner per atomarem rename() in einen Trash-Ordner, der detached im Hintergrund gelöscht wird. Die Bilder kommen über die regulären Scraper-Runden bzw. images:refresh-missing --force frisch + sharded zurück.
Pflicht-Checkliste nach einem Backend-Wechsel (z. B. zurück auf local)
PUBLIC_STORAGE_DRIVER=localin der Production-.envPUBLIC_STORAGE_URLauf den lokalen Pfad (${APP_URL}/storage) — NICHT mehr auf die alte RustFS/R2-Domain, sonst zeigen alleStorage::url()-Links ins Leere (404), obwohl die Files lokal vorhanden sind- Symlink:
php artisan storage:link(public/storage→storage/app/public/) - Config-Cache neu:
php artisan config:cache - Stichprobe: ein paar Profile aufrufen — Bilder müssen lokal laden. Konsistenz prüfen mit
php artisan storage:shard-verify --dry-run(originals_okvsoriginals_missing)
Was NICHT angefasst werden darf
| Pfad | Begruendung |
|---|---|
social_profile_daily_metrics (DB) | Tägliche Verlaufsdaten aller Profile — niemals löschen/prunen (Herzstück der App) |
storage/framework/cache/ | Laravel Cache-Dateien |
storage/logs/ (aktuelle Logs) | Laufende Logs behalten |