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:
- URL Parsing --
YouTubeUrlParser::parse()erkennt den URL-Typ (channel,handle,username,video,root,custom) - 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)
- Handle ->
- Profil-Erstellung --
EnsureSocialProfileFromUrl::execute()speichert die Daten:SocialProfilewird erstellt/aktualisiert (Channel-ID, Handle, Titel, Thumbnail, Country, Published-At)SocialProfileDailyMetricfuer den aktuellen Tag (Subscriber, Views, Videos, Comments)- Profilbild wird heruntergeladen und in Storage persistiert
ProfileDescriptionParserextrahiert 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)
| Gruppe | Beschreibung |
|---|---|
| PRO | Profile mit auto_sync_enabled = true (YouTubeVideoSync) |
| Leaders | Profile in Top/Flop 100 Leaderboards |
| Candidates | Naechste 200 Profile um die Leaderboard-Grenzen |
| Favorites | Global favorisierte Profile |
| New | Noch nie gescraped (last_scraped_at IS NULL) |
| CatchUp | Letzter 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:
syncPlaylistVideos()faengtRuntimeExceptionmit Code 404- Statt Exception zu werfen, setzt es
$this->playlistNotFound = trueund kehrt zurueck handle()prueft das Flag: stattrecordSuccess()wirdrecordFailure()aufgerufen- 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-Typ | Verhalten | Fail-Streak | Retry |
|---|---|---|---|
YouTubeQuotaExceededException | Circuit Breaker oeffnen, Job beenden | NEIN | Nein (naechster Zyklus) |
ConnectionException (SSL/DNS/Timeout) | Re-throw, kein recordFailure() | NEIN | Ja (30min, 1h, 2h Backoff) |
Sonstige Throwable | recordFailure() + re-throw | JA (+1/Tag) | Ja (30min, 1h, 2h Backoff) |
| Playlist 404 | Soft-Failure (recordFailure()) | JA (+1/Tag) | Nein |
- Fail-Streak: Nach 7 konsekutiven Fail-Tagen wird
auto_syncautomatisch 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
- Admin klickt "Mehr Video-Statistiken" auf der Watcher-Detailseite
SyncYouTubeVideoStatsJob wird inimports-youtube-video-priorityQueue dispatcht- Job fuehrt Full-Sync durch:
channels.list(contentDetails) ->uploadsPlaylistIdplaylistItems.list-> alle Video-IDs (max 50 Videos, letzte 12 Monate)videos.list(snippet, statistics, contentDetails, status) -> volle Metadaten
- Ergebnisse in
youtube_videos+youtube_video_daily_metrics
Auto-Sync (naechtlicher Delta-Sync)
Steuerung ueber youtube_video_syncs Tabelle:
| Feld | Beschreibung |
|---|---|
auto_sync_enabled | Master-Schalter fuer naechtlichen Sync |
last_video_count | Letzter bekannter videoCount fuer Delta-Erkennung |
last_full_sync_at | Zeitpunkt 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
Related Channels
Automatische Erkennung verwandter YouTube-Kanaele per API-Suche.
- User/Admin klickt "Related Channels finden" ->
FindRelatedYouTubeChannelsJob dispatcht - Job sucht via
search.list(research Key-Pool) nach Channels mit aehnlichem Titel und Topic-IDs - 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)
- Neue Channels werden automatisch importiert (
EnsureSocialProfileFromUrl+ Admin-Workspace) - Top 25 werden als
youtube_related_channelsgespeichert - Bei Quota-Erschoepfung: verbleibende Channels in
pending_youtube_channel_importsQueue
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
| Typ | Pattern | Beispiel |
|---|---|---|
| Web-URLs | https?://, www., Bare Domains | https://meinshop.de |
| E-Mail-Adressen | user@domain.tld | kontakt@firma.de |
| Social Handles | Plattform-URL-Patterns, @handle | instagram.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:
| Part | Quota-Kosten | Daten |
|---|---|---|
snippet | inkl. in 1 Unit | Titel, Beschreibung, Thumbnail, Country, Custom URL, Published At |
statistics | inkl. in 1 Unit | Subscriber, Views, Videos, Comments |
contentDetails | inkl. in 1 Unit | Uploads Playlist ID (fuer Video-Sync) |
brandingSettings | inkl. in 1 Unit | Banner, Keywords, Unsubscribed Trailer |
topicDetails | inkl. in 1 Unit | Topic Categories, Wikipedia URLs |
status | inkl. in 1 Unit | Privacy, 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):
pro_enabled = truesetzenYouTubeVideoSync-Eintrag erstellen mitauto_sync_enabled = true+video_tracking_start_date- Initialen
SyncYouTubeVideoStats-Job dispatchen (nur wenn noch keine Videos vorhanden) FindRelatedYouTubeChannels-Job dispatchen (wenn noch nicht recherchiert)- 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_datewird 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);"
| Parameter | Beschreibung |
|---|---|
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);"
| Parameter | Beschreibung |
|---|---|
followers_count >= 3000000 | Schwelle — 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):
| Parameter | Beschreibung |
|---|---|
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):
- Ermittelt YouTube-Profile nach Kriterium (Score oder Follower-Anzahl)
- Filtert bereits PRO-markierte und ausgeschlossene Profile heraus
- Zeigt eine Vorschau der ersten 10 Treffer (ID, Titel, Follower/Score)
- Pro Profil:
pro_enabled = truesetzen,YouTubeVideoSyncmitauto_sync_enabled = trueerstellen, 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
Related Profiles fuer PRO-Profile anstoßen
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:
- Cross-Platform Related — Findet zugehoerige Profile auf der anderen Plattform (YouTube → Instagram, Instagram → YouTube). Nur lokale DB-Abfragen, keine API-Kosten.
- 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);"
Schritt 2 — Cross-Platform Related dispatchen (keine API-Kosten)
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);"
Schritt 3 — YouTube Related Channels dispatchen (Quota-Kosten!)
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.
| Parameter | Beschreibung |
|---|---|
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:
WebSubManager::subscribe($profile)registriert die Subscription beim Hub- Hub sendet GET-Request an Callback-URL mit Challenge → Controller echoed Challenge zurueck
- Hub sendet POST mit Atom-XML an Callback-URL wenn neues Video veroeffentlicht wird
- Controller validiert HMAC-Signatur (Graceful: Warning-Log bei Mismatch, aber 200 OK statt Reject), parsed XML, dispatcht
FetchNewYouTubeVideo-Job - 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
| Variable | Default | Beschreibung |
|---|---|---|
POSTBOX_YOUTUBE_ROTATION_DAYS | 3 | Anzahl 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_ENABLED | true | WebSub Push-Notifications aktivieren |
YOUTUBE_WEBSUB_SECRET_PREFIX | -- | Prefix fuer HMAC-Secret-Generierung |
YOUTUBE_WEBSUB_LEASE_SECONDS | 864000 | Gewuenschte Lease-Dauer (10 Tage) |
YOUTUBE_WEBSUB_RENEWAL_HOURS | 48 | Stunden vor Ablauf fuer proaktive Erneuerung |
YOUTUBE_RSS_POLL_ENABLED | true | RSS-Fallback-Polling aktivieren |
YOUTUBE_RSS_POLL_INTERVAL | 30 | Polling-Intervall in Minuten |
YOUTUBE_RSS_BATCH_SIZE | 100 | Channels pro Polling-Durchlauf |
YOUTUBE_RSS_CONCURRENCY | 10 | Parallele HTTP-Requests beim RSS-Polling |
YOUTUBE_DETECTION_QUEUE | imports-youtube-video-priority | Queue fuer Video-Detection-Jobs |
YOUTUBE_PRO_MAX_VIDEOS | 100 | Maximale Videos pro PRO-Profil |