Zum Hauptinhalt springen

YouTube Pipeline

End-to-End-Ablauf vom Channel-Import bis zur Description-Analyse.

Channel Import

Der Import startet mit einer YouTube-URL (Channel, Handle, Video, Username) und durchlaueft folgende Schritte:

  1. URL Parsing -- YouTubeUrlParser::parse() erkennt den URL-Typ (channel, handle, username, video, root, custom)
  2. Channel Resolution -- YouTubeChannelResolver::resolve() loest die URL ueber die YouTube Data API auf:
    • Handle -> channels.list?forHandle=...
    • Username -> channels.list?forUsername=...
    • Channel-ID -> channels.list?id=...
    • Video-ID -> videos.list (snippet) -> channelId -> channels.list
    • Fallback-Kaskade: Handle -> Username -> Username -> Handle (mit Lowercase-Varianten)
  3. Profil-Erstellung -- EnsureSocialProfileFromUrl::execute() speichert die Daten:
    • SocialProfile wird erstellt/aktualisiert (Channel-ID, Handle, Titel, Thumbnail, Country, Published-At)
    • SocialProfileDailyMetric fuer den aktuellen Tag (Subscriber, Views, Videos, Comments)
    • Profilbild wird heruntergeladen und in Storage persistiert
    • ProfileDescriptionParser extrahiert Links aus der Beschreibung

Location: app/Services/Social/YouTube/YouTubeChannelResolver.php, app/Services/Social/Actions/EnsureSocialProfileFromUrl.php, app/Services/Social/YouTube/YouTubeDataApiClient.php

Daily Scrape

Der taegliche YouTube-Scrape laeuft ueber social:scrape-daily-followers (alle 2h, ungerade UTC-Stunden).

Rotation-Bucket-System

Profile werden deterministisch auf Rotation-Buckets verteilt:

$key = $profile->handle_normalized ?: $profile->handle ?: (string) $profile->id;
$hash = crc32(Str::lower($key));
$bucket = abs($hash) % $rotationDays; // Default: 3 Tage

Der aktuelle Bucket ergibt sich aus dayOfYear % rotationDays. Nur Profile im heutigen Bucket werden gescraped -- Priority-Profile umgehen die Rotation.

Priority-Profile (taeglicher Scrape)

GruppeBeschreibung
PROProfile mit auto_sync_enabled = true (YouTubeVideoSync)
LeadersProfile in Top/Flop 100 Leaderboards
CandidatesNaechste 200 Profile um die Leaderboard-Grenzen
FavoritesGlobal favorisierte Profile
NewNoch nie gescraped (last_scraped_at IS NULL)
CatchUpLetzter Scrape liegt >rotationDays zurueck

Ablauf pro Profil

EnsureSocialProfileFromUrl::execute('youtube', $canonicalUrl)
-> YouTubeChannelResolver::resolve($url)
-> YouTubeDataApiClient::get('channels', part=snippet,statistics)
-> SocialProfile upsert (external_id, handle, title, description, thumbnail, country, ...)
-> SocialProfileDailyMetric upsert (followers_count, view_count, video_count, comment_count)
-> ProfileDescriptionParser::parse() -> parsed_links JSONB update

Fehlerbehandlung mit Fail-Streak: Nach 14 aufeinanderfolgenden permanenten Fehlern (404, subscriber hidden) wird tracking_enabled = false gesetzt und das Profil in den Retry-Lifecycle ueberfuehrt.

Location: app/Console/Commands/SocialScrapeDailyFollowers.php

Playlist-404 Handling (Video Sync)

SyncYouTubeVideoStats behandelt Playlist-404-Fehler als Soft-Failure:

  1. syncPlaylistVideos() faengt RuntimeException mit Code 404
  2. Statt Exception zu werfen, setzt es $this->playlistNotFound = true und kehrt zurueck
  3. handle() prueft das Flag: statt recordSuccess() wird recordFailure() aufgerufen
  4. Der Fail-Streak zaehlt hoch — nach MAX_CONSECUTIVE_FAIL_DAYS (7 Tage) wird Auto-Sync deaktiviert

Dies verhindert, dass Kanaele mit permanent geloeschten Playlists endlos als "erfolgreich" markiert werden und der Fail-Streak nie greift.

Location: app/Jobs/YouTube/SyncYouTubeVideoStats.php

Error-Handling (SyncYouTubeVideoStats)

Der Video Sync Job hat differenziertes Error-Handling:

Exception-TypVerhaltenFail-StreakRetry
YouTubeQuotaExceededExceptionCircuit Breaker oeffnen, Job beendenNEINNein (naechster Zyklus)
ConnectionException (SSL/DNS/Timeout)Re-throw, kein recordFailure()NEINJa (30min, 1h, 2h Backoff)
Sonstige ThrowablerecordFailure() + re-throwJA (+1/Tag)Ja (30min, 1h, 2h Backoff)
Playlist 404Soft-Failure (recordFailure())JA (+1/Tag)Nein
  • Fail-Streak: Nach 7 konsekutiven Fail-Tagen wird auto_sync automatisch deaktiviert
  • Timeout: Explizit 600 Sekunden (10 Min.) — verhindert endlos laufende Jobs bei API-Haengern
  • SSL-Resilience: Transiente Netzwerkfehler (ConnectionException) erhoehen den Fail-Streak-Zaehler nicht. 7 Tage SSL-Timeouts fuehren daher nicht mehr zur permanenten Deaktivierung von Auto-Sync.

Video Sync

Erweiterte Video-Statistiken pro YouTube-Kanal, manuell ausgeloest oder per naechtlichem Auto-Sync.

Manueller Trigger

  1. Admin klickt "Mehr Video-Statistiken" auf der Watcher-Detailseite
  2. SyncYouTubeVideoStats Job wird in imports-youtube-video-priority Queue dispatcht
  3. Job fuehrt Full-Sync durch:
    • channels.list (contentDetails) -> uploadsPlaylistId
    • playlistItems.list -> alle Video-IDs (max 50 Videos, letzte 12 Monate)
    • videos.list (snippet, statistics, contentDetails, status) -> volle Metadaten
  4. Ergebnisse in youtube_videos + youtube_video_daily_metrics

Auto-Sync (naechtlicher Delta-Sync)

Steuerung ueber youtube_video_syncs Tabelle:

FeldBeschreibung
auto_sync_enabledMaster-Schalter fuer naechtlichen Sync
last_video_countLetzter bekannter videoCount fuer Delta-Erkennung
last_full_sync_atZeitpunkt des letzten vollstaendigen Syncs

Delta-Sync-Logik: Wenn videoCount sich nicht geaendert hat, werden nur tageaktuelle Statistiken fuer bekannte Videos geholt (Schritte 2+3 uebersprungen). An ca. 90% der Tage spart dies 50% der API-Calls.

Location: app/Jobs/YouTube/SyncYouTubeVideoStats.php

Automatische Erkennung verwandter YouTube-Kanaele per API-Suche.

  1. User/Admin klickt "Related Channels finden" -> FindRelatedYouTubeChannels Job dispatcht
  2. Job sucht via search.list (research Key-Pool) nach Channels mit aehnlichem Titel und Topic-IDs
  3. Relevance-Score (0-100) wird berechnet:
    • Exists in Postbox: +35 Punkte
    • Subscriber Bonus: 0-20 Punkte
    • Favorites Bonus: 0-10 Punkte
    • Position Bonus: 1-20 Punkte
    • Topic Match: +10 Punkte
    • Title Similarity: 0-5 Punkte
    • "- Topic" De-Rank: -50% (auto-generierte YouTube-Sammelkanaele)
  4. Neue Channels werden automatisch importiert (EnsureSocialProfileFromUrl + Admin-Workspace)
  5. Top 25 werden als youtube_related_channels gespeichert
  6. Bei Quota-Erschoepfung: verbleibende Channels in pending_youtube_channel_imports Queue

Auto-Fill Command (youtube:auto-fill-related-channels) laeuft alle 5 Minuten und triggert Suchen fuer Profile ohne Related Channels, sofern >25% Research-Quota verfuegbar.

Location: app/Jobs/YouTube/FindRelatedYouTubeChannels.php, app/Console/Commands/AutoFillRelatedYouTubeChannels.php

Description Parsing

ProfileDescriptionParser analysiert Bio-Texte und extrahiert strukturierte Daten.

Extrahierte Typen

TypPatternBeispiel
Web-URLshttps?://, www., Bare Domainshttps://meinshop.de
E-Mail-Adressenuser@domain.tldkontakt@firma.de
Social HandlesPlattform-URL-Patterns, @handleinstagram.com/user, @myhandle

Unterstuetzte Plattformen: Instagram, YouTube, Twitter/X, TikTok, Facebook, Twitch, LinkedIn, Threads.

Gespeichert in social_profiles.parsed_links (JSONB, Append-Only, keine Duplikate). Wird automatisch aufgerufen bei Import, Daily Scrape und Instagram-Verarbeitung.

Location: app/Services/Social/ProfileDescriptionParser.php

API Parts

Der Channel-Request nutzt folgende YouTube Data API Parts:

PartQuota-KostenDaten
snippetinkl. in 1 UnitTitel, Beschreibung, Thumbnail, Country, Custom URL, Published At
statisticsinkl. in 1 UnitSubscriber, Views, Videos, Comments
contentDetailsinkl. in 1 UnitUploads Playlist ID (fuer Video-Sync)
brandingSettingsinkl. in 1 UnitBanner, Keywords, Unsubscribed Trailer
topicDetailsinkl. in 1 UnitTopic Categories, Wikipedia URLs
statusinkl. in 1 UnitPrivacy, Made for Kids, Long Uploads

Alle Parts werden in social_profiles.data (JSONB) gespeichert. Nur explizit benoetigte Felder werden in dedizierte Spalten extrahiert.

PRO Aktivierung

Top-YouTube-Profile koennen als PRO markiert werden. PRO-Profile erhalten taeglichen Priority-Scrape (siehe Priority-Profile) und automatischen Video-Sync (Erweiterte Statistiken).

WICHTIG: pro_enabled allein reicht NICHT — es muss zusaetzlich ein YouTubeVideoSync-Eintrag mit auto_sync_enabled = true erstellt werden, damit der automatische Video-Abruf laeuft. Ohne diesen Eintrag zeigt das Profil nur das PRO-Badge, aber Videos werden nicht abgeholt.

Automatische Aktivierung (Scheduled Command)

Der Command youtube:auto-activate-pro laeuft taeglich um 01:00 UTC und aktiviert automatisch PRO fuer YouTube-Profile oberhalb einer konfigurierbaren Follower-Schwelle.

Was der Command macht (pro Profil):

  1. pro_enabled = true setzen
  2. YouTubeVideoSync-Eintrag erstellen mit auto_sync_enabled = true + video_tracking_start_date
  3. Initialen SyncYouTubeVideoStats-Job dispatchen (nur wenn noch keine Videos vorhanden)
  4. FindRelatedYouTubeChannels-Job dispatchen (wenn noch nicht recherchiert)
  5. Reparatur-Phase: Bestehende PRO-Profile mit fehlenden/deaktivierten VideoSync-Eintraegen fixen

Config:

# Automatische PRO-Aktivierung ab Follower-Schwelle (Default: 1 Mio.)
YOUTUBE_PRO_AUTO_ACTIVATION_ENABLED=true
YOUTUBE_PRO_MIN_FOLLOWERS=1000000

Manueller Aufruf:

# Vorschau (keine Aenderungen)
php artisan youtube:auto-activate-pro --dry-run

# Mit anderer Schwelle
php artisan youtube:auto-activate-pro --min-followers=500000

Verhalten:

  • Einmal PRO = bleibt PRO. Keine Deaktivierung bei Follower-Verlust.
  • Deaktivierung nur bei technischem Fehler (7 konsekutive Fail-Days im Video-Sync).
  • Bestehende video_tracking_start_date wird bei Re-Aktivierung beibehalten.
  • Health-Heartbeat: youtube_pro_auto_activate

Manuelle Bulk-Aktivierung (Tinker-Befehle)

Fuer einmalige Aktivierungen ausserhalb des automatischen Schedulers koennen weiterhin Tinker-Befehle genutzt werden.

Variante A: Nach Postbox-Score (Top-N)

Aktiviert die N Profile mit dem hoechsten Postbox-Score.

Schritt 1+2 — Vorschau:

php artisan tinker --execute="\$topIds = \App\Models\SocialProfileScore::query()->select('social_profile_id')->whereIn('social_profile_id', \App\Models\SocialProfile::query()->where('platform', 'youtube')->whereNull('blocked_at')->whereNull('sanitized_at')->whereNull('archived_at')->where('pro_enabled', false)->select('id'))->whereNotNull('score')->where('date', \App\Models\SocialProfileScore::query()->max('date'))->orderByDesc('score')->limit(50)->pluck('social_profile_id'); echo 'Gefundene Profile: ' . \$topIds->count() . chr(10); \App\Models\SocialProfile::query()->whereIn('id', \$topIds->take(10))->get(['id', 'title', 'platform', 'pro_enabled'])->each(function(\$p) { echo \$p->id . ': ' . \$p->title . ' (pro=' . \$p->pro_enabled . ')' . chr(10); });"

Schritt 3 — Aktivierung:

php artisan tinker --execute="\$topIds = \App\Models\SocialProfileScore::query()->select('social_profile_id')->whereIn('social_profile_id', \App\Models\SocialProfile::query()->where('platform', 'youtube')->whereNull('blocked_at')->whereNull('sanitized_at')->whereNull('archived_at')->where('pro_enabled', false)->select('id'))->whereNotNull('score')->where('date', \App\Models\SocialProfileScore::query()->max('date'))->orderByDesc('score')->limit(50)->pluck('social_profile_id'); \$count = 0; \App\Models\SocialProfile::query()->whereIn('id', \$topIds)->each(function(\$profile) use (&\$count) { \$profile->update(['pro_enabled' => true]); \App\Models\YouTubeVideoSync::query()->updateOrCreate(['social_profile_id' => \$profile->id], ['auto_sync_enabled' => true, 'requested_by' => null]); if (false === \App\Models\YouTubeVideo::where('social_profile_id', \$profile->id)->exists()) { \App\Jobs\YouTube\SyncYouTubeVideoStats::dispatch(socialProfileId: \$profile->id, triggeredManually: true)->onQueue('imports-youtube-video-priority'); } \$count++; }); echo 'PRO + Auto-Sync aktiviert fuer ' . \$count . ' Profile.' . chr(10);"
ParameterBeschreibung
limit(50)Anzahl der Top-Profile — bei Bedarf erhoehen
where('pro_enabled', false)Ueberspringt bereits markierte PRO-Profile
whereNull('blocked_at/sanitized_at/archived_at')Nur aktive, nicht-ausgeschlossene Profile

Variante B: Nach Follower-Anzahl (Minimum-Schwelle)

Aktiviert alle Profile ab einer bestimmten Follower-Anzahl. Schwelle anpassbar (z.B. 3000000 = 3 Mio.).

Schritt 1+2 — Vorschau:

php artisan tinker --execute="\$topIds = \App\Models\SocialProfileDailyMetric::query()->select('social_profile_id')->whereIn('social_profile_id', \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('tracking_enabled', true)->whereNull('blocked_at')->whereNull('sanitized_at')->whereNull('archived_at')->where('pro_enabled', false)->select('id'))->where('date', \App\Models\SocialProfileDailyMetric::query()->max('date'))->where('followers_count', '>=', 3000000)->orderByDesc('followers_count')->pluck('social_profile_id'); echo 'Gefundene Profile: ' . \$topIds->count() . chr(10); \App\Models\SocialProfile::query()->whereIn('id', \$topIds->take(10))->get(['id', 'title', 'platform', 'pro_enabled'])->each(function(\$p) { \$f = \App\Models\SocialProfileDailyMetric::where('social_profile_id', \$p->id)->orderByDesc('date')->value('followers_count'); echo \$p->id . ': ' . \$p->title . ' — ' . number_format(\$f, 0, ',', '.') . ' Follower' . chr(10); });"

Schritt 3 — Aktivierung:

php artisan tinker --execute="\$topIds = \App\Models\SocialProfileDailyMetric::query()->select('social_profile_id')->whereIn('social_profile_id', \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('tracking_enabled', true)->whereNull('blocked_at')->whereNull('sanitized_at')->whereNull('archived_at')->where('pro_enabled', false)->select('id'))->where('date', \App\Models\SocialProfileDailyMetric::query()->max('date'))->where('followers_count', '>=', 3000000)->orderByDesc('followers_count')->pluck('social_profile_id'); \$count = 0; \App\Models\SocialProfile::query()->whereIn('id', \$topIds)->each(function(\$profile) use (&\$count) { \$profile->update(['pro_enabled' => true]); \App\Models\YouTubeVideoSync::query()->updateOrCreate(['social_profile_id' => \$profile->id], ['auto_sync_enabled' => true, 'requested_by' => null]); if (false === \App\Models\YouTubeVideo::where('social_profile_id', \$profile->id)->exists()) { \App\Jobs\YouTube\SyncYouTubeVideoStats::dispatch(socialProfileId: \$profile->id, triggeredManually: true)->onQueue('imports-youtube-video-priority'); } \$count++; }); echo 'PRO + Auto-Sync aktiviert fuer ' . \$count . ' Profile.' . chr(10);"
ParameterBeschreibung
followers_count >= 3000000Schwelle — anpassen auf gewuenschte Mindest-Follower-Anzahl
where('pro_enabled', false)Ueberspringt bereits markierte PRO-Profile
where('tracking_enabled', true)Nur aktiv getrackte Profile
whereNull('blocked_at/sanitized_at/archived_at')Nur aktive, nicht-ausgeschlossene Profile

Shell-Hinweise

  • Alle Befehle sind einzeilig fuer Copy-Paste in die Server-Shell
  • \$ statt $ (Bash-Escaping fuer Variablen innerhalb von Double-Quotes)
  • false === statt ! (Bash interpretiert ! als History-Expansion)
  • chr(10) statt \n (zuverlaessiger Zeilenumbruch in Tinker-Einzeilern)
  • Immer zuerst Schritt 1+2 (Vorschau) ausfuehren, dann erst Schritt 3 (Aktivierung)

Troubleshooting: T_NS_SEPARATOR-Error beim Copy-Paste:

Wenn nach dem Einfuegen eines Befehls der Fehler PHP Parse error: Syntax error, unexpected T_NS_SEPARATOR on line 1 erscheint, wurden beim Kopieren die Backslashes vor $-Zeichen verschluckt. Bash expandiert dann $count, $profiles, $p etc. als Shell-Variablen (leer), und PHP erhaelt ungueltigen Code wie = 0; \App\Models\....

Loesung: Befehl in einem Texteditor oeffnen und pruefen, dass alle PHP-Variablen als \$variable escaped sind (z.B. \$count, \$profiles, \$p, \$q). Namespace-Backslashes (\App\Models\...) muessen OHNE zusaetzliches Escaping stehen bleiben.

Reparatur: PRO-Profile ohne Video-Sync fixen

Falls Profile bereits mit pro_enabled = true markiert wurden, aber kein YouTubeVideoSync-Eintrag existiert:

php artisan tinker --execute="\$broken = \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('pro_enabled', true)->whereDoesntHave('youtubeVideoSync', fn(\$q) => \$q->where('auto_sync_enabled', true))->get(['id', 'title']); echo 'Betroffene Profile: ' . \$broken->count() . chr(10); \$broken->each(function(\$profile) { \App\Models\YouTubeVideoSync::query()->updateOrCreate(['social_profile_id' => \$profile->id], ['auto_sync_enabled' => true, 'requested_by' => null]); if (false === \App\Models\YouTubeVideo::where('social_profile_id', \$profile->id)->exists()) { \App\Jobs\YouTube\SyncYouTubeVideoStats::dispatch(socialProfileId: \$profile->id, triggeredManually: true)->onQueue('imports-youtube-video-priority'); } echo 'Fixed: ' . \$profile->id . ' (' . \$profile->title . ')' . chr(10); });"

Gemeinsame Parameter (alle Varianten):

ParameterBeschreibung
updateOrCreate(YouTubeVideoSync)Erstellt/aktualisiert Video-Sync-Eintrag mit auto_sync_enabled = true
SyncYouTubeVideoStats::dispatch()Initialer Video-Abruf nur wenn noch keine Videos vorhanden

Ablauf (alle Varianten):

  1. Ermittelt YouTube-Profile nach Kriterium (Score oder Follower-Anzahl)
  2. Filtert bereits PRO-markierte und ausgeschlossene Profile heraus
  3. Zeigt eine Vorschau der ersten 10 Treffer (ID, Titel, Follower/Score)
  4. Pro Profil: pro_enabled = true setzen, YouTubeVideoSync mit auto_sync_enabled = true erstellen, initialen Video-Sync dispatchen

Location: Manueller Tinker-Befehl, betrifft app/Models/SocialProfile.php (pro_enabled), app/Models/YouTubeVideoSync.php (auto_sync_enabled), app/Jobs/YouTube/SyncYouTubeVideoStats.php

Nach einer PRO-Bulk-Aktivierung muessen fuer die neuen PRO-Profile die Related-Berechnungen angestoßen werden (Related Channels + Cross-Platform). Die AutoFill-Commands laufen zwar alle 5 Minuten, aber bei grossen Mengen (>1000 Profile) dauert das Wochen. Per Tinker lassen sich alle Jobs sofort dispatchen.

Drei Berechnungen:

  1. Cross-Platform Related — Findet zugehoerige Profile auf der anderen Plattform (YouTube → Instagram, Instagram → YouTube). Nur lokale DB-Abfragen, keine API-Kosten.
  2. YouTube Related Channels — Findet aehnliche YouTube-Channels per Keyword-/Kategorie-Matching + YouTube Search API. Kostet YouTube API Quota (Research-Budget). Jobs sind quota-aware und pausieren automatisch bei Quota-Erschoepfung.

Schritt 1 — Vorschau (wie viele Profile brauchen Berechnungen)

php artisan tinker --execute="\$profiles = \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('pro_enabled', true)->where('tracking_enabled', true)->whereNull('blocked_at')->whereNull('sanitized_at')->whereNull('archived_at')->get(['id', 'title', 'related_channels_status', 'related_channels_searched_at', 'cross_platform_related_status', 'cross_platform_related_calculated_at']); \$needsYt = \$profiles->filter(fn(\$p) => \$p->related_channels_searched_at === null && false === in_array(\$p->related_channels_status, ['running', 'pending'], true))->count(); \$needsCp = \$profiles->filter(fn(\$p) => \$p->cross_platform_related_calculated_at === null && false === in_array(\$p->cross_platform_related_status, ['running', 'pending'], true))->count(); echo 'PRO YouTube Profile gesamt: ' . \$profiles->count() . chr(10) . 'Brauchen Related Channels: ' . \$needsYt . chr(10) . 'Brauchen Cross-Platform: ' . \$needsCp . chr(10);"

Dispatcht FindCrossPlatformRelatedProfiles-Jobs fuer alle PRO YouTube-Profile ohne bestehende Berechnung. Sicher und kostenlos.

php artisan tinker --execute="\$count = 0; \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('pro_enabled', true)->where('tracking_enabled', true)->whereNull('blocked_at')->whereNull('sanitized_at')->whereNull('archived_at')->whereNull('cross_platform_related_calculated_at')->where(function(\$q) { \$q->whereNull('cross_platform_related_status')->orWhereNotIn('cross_platform_related_status', ['running', 'pending']); })->select('id')->chunkById(100, function(\$profiles) use (&\$count) { foreach (\$profiles as \$p) { \App\Jobs\CrossPlatform\FindCrossPlatformRelatedProfiles::dispatch(\$p->id)->onQueue('cross-platform-related'); \$count++; } }); echo 'Cross-Platform Related dispatched: ' . \$count . chr(10);"

Dispatcht FindRelatedYouTubeChannels-Jobs fuer alle PRO YouTube-Profile ohne bestehende Suche. Achtung: Jeder Job verbraucht YouTube API Research-Quota. Die Jobs haben eingebauten YouTubeQuotaGuard und Rate-Limiting — sie pausieren automatisch wenn Quota knapp wird.

php artisan tinker --execute="\$count = 0; \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('pro_enabled', true)->where('tracking_enabled', true)->whereNull('blocked_at')->whereNull('sanitized_at')->whereNull('archived_at')->whereNull('related_channels_searched_at')->where(function(\$q) { \$q->whereNull('related_channels_status')->orWhereNotIn('related_channels_status', ['running', 'pending']); })->select('id')->chunkById(100, function(\$profiles) use (&\$count) { foreach (\$profiles as \$p) { \App\Jobs\YouTube\FindRelatedYouTubeChannels::dispatch(\$p->id)->onQueue('youtube-related-channels'); \$count++; } }); echo 'YouTube Related Channels dispatched: ' . \$count . chr(10);"

SQL NULL-Hinweis: Die where(function($q) { whereNull(...)->orWhereNotIn(...) })-Konstruktion ist noetig, weil whereNotIn('status', ['running', 'pending']) in SQL auch NULL-Werte ausschliesst (NULL NOT IN (...) evaluiert zu NULL, nicht TRUE). Profile ohne bisherigen Status haben status = NULL und wuerden sonst uebersehen.

ParameterBeschreibung
whereNull('*_calculated_at')Nur Profile ohne bestehende Berechnung
whereNull('*_status')->orWhereNotIn(...)NULL-safe Filter: schliesst nur laufende/pending Jobs aus
chunkById(100)Memory-schonend: 100 Profile pro DB-Query
->onQueue('cross-platform-related')Queue fuer Cross-Platform Jobs
->onQueue('youtube-related-channels')Queue fuer YouTube Related Channel Jobs (quota-aware)

Troubleshooting: Siehe Shell-Hinweise → T_NS_SEPARATOR falls beim Einfuegen ein Parse-Error auftritt.

New-Video-Detection (WebSub + RSS Hybrid)

Dreistufige Video-Erkennung fuer PRO-Profile:

Layer 1: WebSub Push Notifications (Primaer)

Google betreibt einen PubSubHubbub-Hub (pubsubhubbub.appspot.com), der Push-Notifications sendet wenn ein YouTube-Channel ein neues Video veroeffentlicht. 0 API-Quota, Echtzeit-Erkennung.

Ablauf:

  1. WebSubManager::subscribe($profile) registriert die Subscription beim Hub
  2. Hub sendet GET-Request an Callback-URL mit Challenge → Controller echoed Challenge zurueck
  3. Hub sendet POST mit Atom-XML an Callback-URL wenn neues Video veroeffentlicht wird
  4. Controller validiert HMAC-Signatur (Graceful: Warning-Log bei Mismatch, aber 200 OK statt Reject), parsed XML, dispatcht FetchNewYouTubeVideo-Job
  5. Job fetcht Video-Details via videos.list (1 Quota-Unit) und speichert in DB

HMAC-Signatur-Handling: Der Controller validiert die HMAC-Signatur aus dem X-Hub-Signature Header. Bei ungueltigem Signature wird eine Warning geloggt, die Notification aber trotzdem verarbeitet (200 OK). Ein harter 403-Reject wuerde bei Secret-Rotation (waehrend Renewal signiert Google noch mit dem alten Secret) zu Retry-Kaskaden fuehren.

Secret-Retention bei Renewal: subscribe() verwendet den bestehenden HMAC-Secret des Channels statt einen neuen zu generieren. So bleibt die Signatur-Validierung auch waehrend der Renewal-Phase konsistent.

Rate-Limiting: 3000 Requests/Minute pro IP (Google sendet alle Notifications von wenigen IPs). Zu niedrige Limits fuehren zu 429-Kaskaden durch Googles Retry-Mechanismus.

Lease-Management: Subscriptions laufen nach Lease-Dauer ab (Default: 10 Tage). youtube:manage-websub --renew erneuert proaktiv (48h vor Ablauf). Fehlgeschlagene Renewals werden bis 5x wiederholt.

Location: app/Services/Social/YouTube/WebSubManager.php, app/Http/Controllers/Webhooks/WebSubCallbackController.php

Layer 2: RSS-Feed-Polling (Fallback)

RSS-Feeds (youtube.com/feeds/videos.xml?channel_id=...) werden alle 30 Minuten gepollt. Fangt Videos auf die WebSub nicht meldet (z.B. bei Hub-Ausfall oder Subscription-Luecken).

Optimierungen:

  • ETag/If-Modified-Since-Caching vermeidet unnoetige Downloads (304 Not Modified)
  • Batch-Polling via Http::pool() mit konfigurierbarer Concurrency (Default: 10)
  • Aelteste zuerst: Profile sortiert nach rss_last_poll_at ASC
  • Memory-Management: unset() + gc_collect_cycles() nach jedem Chunk — SimpleXML DOM-Trees halten zirkulaere Referenzen die PHPs Refcount-GC nicht freigeben kann

Location: app/Services/Social/YouTube/YouTubeRssFeedPoller.php, app/Console/Commands/PollYouTubeRssFeeds.php

Layer 3: Manuelle Video-URL-Addition (Admin)

Admins koennen Video-URLs direkt auf der Watcher-Detailseite hinzufuegen. URL-Resolver unterstuetzt 7 Formate (watch, shorts, embed, live, youtu.be, v/, plain ID). Vorhandene Videos werden ohne API-Call erkannt.

Location: app/Services/Social/YouTube/YouTubeVideoUrlResolver.php, app/Livewire/Watchers/AddYouTubeVideoByUrl.php

Deduplizierung

FetchNewYouTubeVideo implementiert ShouldBeUnique mit 10-Minuten-Window und Key fetch-new-yt-video-{videoId}. Wenn WebSub und RSS dasselbe Video erkennen, wird nur ein API-Call ausgefuehrt.

PRO-Video-Erweiterung

PRO-Profile (auto_sync_enabled = true + video_tracking_start_date) erhalten:

  • Unbegrenztes vorwaerts-wachsendes Video-Tracking ab Aktivierungsdatum
  • Bis zu 100 Videos (konfigurierbar via YOUTUBE_PRO_MAX_VIDEOS)
  • Automatische WebSub-Subscription bei Aktivierung
  • Automatisches WebSub-Unsubscribe bei Deaktivierung

Location: app/Jobs/YouTube/SyncYouTubeVideoStats.php

.env Variablen

VariableDefaultBeschreibung
POSTBOX_YOUTUBE_ROTATION_DAYS3Anzahl der Rotation-Tage fuer Daily Scrape
YOUTUBE_API_KEY--Single Fallback API Key
YOUTUBE_API_KEYS--General Pool (kommasepariert)
YOUTUBE_API_VIDEO_KEYS--Video-Sync Key Pool
YOUTUBE_API_RESEARCH_KEYS--Research/Related Channels Key Pool
YOUTUBE_WEBSUB_CALLBACK_URL--Oeffentliche Callback-URL fuer WebSub-Notifications
YOUTUBE_WEBSUB_ENABLEDtrueWebSub Push-Notifications aktivieren
YOUTUBE_WEBSUB_SECRET_PREFIX--Prefix fuer HMAC-Secret-Generierung
YOUTUBE_WEBSUB_LEASE_SECONDS864000Gewuenschte Lease-Dauer (10 Tage)
YOUTUBE_WEBSUB_RENEWAL_HOURS48Stunden vor Ablauf fuer proaktive Erneuerung
YOUTUBE_RSS_POLL_ENABLEDtrueRSS-Fallback-Polling aktivieren
YOUTUBE_RSS_POLL_INTERVAL30Polling-Intervall in Minuten
YOUTUBE_RSS_BATCH_SIZE100Channels pro Polling-Durchlauf
YOUTUBE_RSS_CONCURRENCY10Parallele HTTP-Requests beim RSS-Polling
YOUTUBE_DETECTION_QUEUEimports-youtube-video-priorityQueue fuer Video-Detection-Jobs
YOUTUBE_PRO_MAX_VIDEOS100Maximale Videos pro PRO-Profil