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

Zählweise für Aufrufe — der Stichtag 24.08.2026​

YouTube hat am 24.08.2026 geändert, was ein Aufruf ist. Seitdem zählt ein Aufruf, sobald die Wiedergabe beginnt — eine Mindestwiedergabezeit gibt es nicht mehr. Das gilt für alle Formate: Videos im Langformat, Livestreams und Podcasts. Für Shorts galt es bereits seit dem 31.03.2025.

Betroffen sind in der Data API videos.statistics.viewCount und channels.statistics.viewCount — also genau die Felder, aus denen die Pipeline view_count befüllt.

Die entscheidende Eigenschaft: die Historie wurde nicht neu berechnet​

Bestehende Videos behalten ihre bisherigen Gesamtzahlen. Nur neue Wiedergaben ab dem Stichtag zählen nach der neuen Logik. Für die Daten heisst das genau:

  • Der kumulative Bestand springt nicht. Ein Video mit 200.000 Aufrufen hat am 24.08. weiterhin 200.000.
  • Die Tages-Rate springt. Dieselbe Zuschauerschaft erzeugt ab dem Stichtag mehr gezählte Aufrufe pro Tag.

Daraus folgt die einzige Faustregel, die man sich merken muss: Alles, was aus Differenzen rechnet, ist betroffen. Alles, was einen Bestand anzeigt, nicht.

Die alte Zahl ist für fremde Kanäle nicht mehr beschaffbar​

Die frühere Zählweise existiert weiter, aber ausschliesslich als engagedViews (YouTube Analytics API) beziehungsweise engaged_views (YouTube Reporting API). Beide verlangen ein OAuth-Token des Kanalinhabers (Scope https://www.googleapis.com/auth/yt-analytics.readonly).

Postbox greift ausschliesslich über API-Schlüssel auf die Data API zu und beobachtet fremde Kanäle. Die alte Zahl ist damit für den Watcher-Bestand nicht verfügbar und wird es ohne eine OAuth-Anbindung auch nicht werden.

Wo der Stichtag im Code steht​

OrtZweck
config/postbox.php → youtube_view_counting.change_dateder Stichtag selbst (ISO-Datum)
App\Support\YouTube\ViewCountingChangeeinziger Ort, der ihn interpretiert
App\Support\Charts\ViewCountingAnnotationMarkierung des Bruchs in Zeitreihen-Diagrammen

ViewCountingChange kennt drei Zählweisen, nicht zwei:

  • legacy — mit Mindestwiedergabezeit, bis einschliesslich 23.08.2026
  • playback_start — ab dem ersten Frame, ab 24.08.2026
  • mixed — ein Zeitraum, der den Stichtag enthält

Die dritte ist die, die beim Umsetzen übersehen wird, weil sie sich wie eine Rundungsfrage anfühlt. Ein Aggregat über einen Zeitraum, der den Wechsel umfasst, gehört keiner der beiden Seiten an; es einer zuzuschlagen behauptet eine Vergleichbarkeit, die der Zeitraum nicht hat.

Was daraus folgt — nach Bereich​

BereichBetroffen?Behandlung
view_count in youtube_video_daily_metrics / social_profile_daily_metricsRohwert, unverändert korrektkeine — die Werte werden nie umgerechnet
Diagramme mit TagesdifferenzenjaBruch wird am Stichtag eingezeichnet, plus erklärender Satz
VideoScoreCalculator::calculateGrowthTrajectory()jaliefert den Neutralwert, solange das 30-Tage-Fenster den Stichtag überspannt
Veröffentlichte Benchmark-Zahlenjajede Aggregat-Zeile trägt ihre Zählweise als Spalte
Anomalie-Erkennungnein — geprüft, nicht angenommenkeine; der Detektor liest den Bestand, und der springt nicht
Likes, Kommentare, Abonnenten, Videoanzahlneinkeine
Quota-Kosten der APIneinkeine

Warum die Anomalie-Erkennung nicht eingreifen musste​

Naheliegend wäre die Sorge, der Detektor halte den Sprung für einen Datenfehler und blende die betroffenen Tage aus — was er täte, denn withoutAnomalies() filtert pending genauso wie confirmed, ein markierter Tag verschwindet also aus dem Diagramm.

Er tut es nicht, und zwar aus einem strukturellen Grund: Der Detektor arbeitet auf dem kumulativen view_count. Der springt nicht. Selbst eine verdreifachte Tagesrate bleibt relativ zum Bestand eines etablierten Videos weit unter der 25-%-Toleranz. Und ein Niveau, das hält, gilt dem Detektor ohnehin als echte Änderung — nur ein Wert, der zurückfällt, ist ein Glitch.

Nachgewiesen in tests/Unit/Services/Anomaly/ViewCountingChangeIsNotAnAnomalyTest.php, einschliesslich der Gegenprobe, dass ein echter Einbruch weiterhin erkannt wird.

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.

Status seit 2026-05-25: Die Auto-Activation per Follower-Schwelle ist auf Production deaktiviert (YOUTUBE_PRO_AUTO_ACTIVATION_ENABLED=false). PRO-Profile werden ausschliesslich manuell aktiviert (Watcher-Detail / Admin-UI / Tinker-Bulk). Hintergrund: Bulk-Aktivierungen ueberlasteten das Sync-System; das Tier-System (Full/Standard/Light) steuert jetzt allein die Sync-Frequenz fuer die kleine Menge manuell aktivierter Profile (~1.200 statt vorher >14.000).

Die Repair-Logik (heilt PRO-Profile mit fehlendem auto_sync_enabled) wurde 2026-05-25 in den eigenen Command youtube:repair-pro-sync extrahiert und ist unabhaengig von der Auto-Activation aktiv (YOUTUBE_PRO_REPAIR_ENABLED=true).

Automatische Aktivierung (Scheduled Command) — DEAKTIVIERT​

Der Command youtube:auto-activate-pro laeuft taeglich um 01:00 UTC, ist aber seit 2026-05-25 per Env-Flag YOUTUBE_PRO_AUTO_ACTIVATION_ENABLED=false standardmaessig deaktiviert. Code-Pfad bleibt erhalten, jederzeit per Env-Flag reaktivierbar.

Was der Command machen wuerde (Phase A — Activate, wenn Flag=true):

  1. pro_enabled = true setzen fuer Profile mit followers_count >= YOUTUBE_PRO_MIN_FOLLOWERS UND pro_auto_activation_blocked_at IS NULL
  2. YouTubeVideoSync-Eintrag erstellen mit auto_sync_enabled = true + video_tracking_start_date = today + requested_by = NULL (markiert auto-Aktivierung, unterscheidbar von manueller requested_by = $user->id)
  3. Initialen SyncYouTubeVideoStats-Job dispatchen (nur wenn noch keine Videos vorhanden, triggeredManually=true bypasst Tier+Bucket-Check)
  4. FindRelatedYouTubeChannels-Job dispatchen (wenn noch nicht recherchiert)

Die frueher hier integrierte Phase B (Repair) wurde in den eigenen Command youtube:repair-pro-sync extrahiert — siehe unten.

Config:

# Master-Switch — seit 2026-05-25 auf false in Production.
YOUTUBE_PRO_AUTO_ACTIVATION_ENABLED=false
# Follower-Schwelle wenn das Flag auf true gestellt wird (Default: 1 Mio).
YOUTUBE_PRO_MIN_FOLLOWERS=1000000
# Budget-Cap pro Run (Default: 500 — verhindert Kapazitaets-Schocks).
YOUTUBE_PRO_MAX_NEW_PER_RUN=500

Manueller Aufruf (zur Inspektion):

# Vorschau (keine Aenderungen) — zeigt "disabled" wenn Flag=false:
php artisan youtube:auto-activate-pro --dry-run

# Mit anderer Schwelle (nur wenn Flag=true):
php artisan youtube:auto-activate-pro --min-followers=500000

PRO Sync-Repair (Scheduled Command)​

Der Command youtube:repair-pro-sync laeuft taeglich um 01:30 UTC (zwischen Auto-Activate und Daily-Sync) und heilt PRO-Profile, denen der youtube_video_syncs.auto_sync_enabled-Flag fehlt oder auf false steht. Unabhaengig von der Auto-Activation — seit 2026-05-25 als eigenstaendiger Command extrahiert.

Wer landet hier:

  • Profile mit pro_enabled = true UND keinem youtube_video_syncs-Eintrag
  • Profile mit pro_enabled = true UND auto_sync_enabled = false (z.B. nach Fail-Streak-Reset)
  • Profile mit pro_auto_activation_blocked_at = NULL (also nicht explizit Plan-67-blockiert)

Was passiert pro Treffer:

  1. YouTubeVideoSync-Eintrag setzen/erstellen mit auto_sync_enabled = true, requested_by = NULL, video_tracking_start_date = today (falls null)
  2. Initialer SyncYouTubeVideoStats-Job dispatchen (falls noch keine Videos da)
  3. FindRelatedYouTubeChannels-Job dispatchen (falls noch nicht recherchiert)

Config:

# Master-Switch (default: true). Auf false setzen wenn auch Repair komplett aus soll.
YOUTUBE_PRO_REPAIR_ENABLED=true

Manueller Aufruf:

# Vorschau (keine Aenderungen):
php artisan youtube:repair-pro-sync --dry-run

# Echter Run:
php artisan youtube:repair-pro-sync

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

PRO Massen-Deaktivierung (mit Auto-Activate-Lock)​

Gegenstueck zur PRO Aktivierung — deaktiviert PRO + Auto-Sync fuer mehrere YouTube-Profile auf einmal, mit dem Plan-67-Lock (pro_auto_activation_blocked_at = now()), damit der youtube:auto-activate-pro-Cron die Profile nicht wieder automatisch reaktiviert.

Wann verwenden​

  • Nicht-englische / nicht-deutsche Kanaele aus der PRO-Stufe nehmen (Sprache/Land-Filter)
  • Profile aus einem bestimmten Land komplett offline nehmen
  • Massen-Bereinigung nach fehlerhaftem Bulk-Import

Disable-Felder (Plan 67)​

Der Tinker setzt pro Profil alle 7 Plan-67-Felder atomar + den auto_sync_enabled=false-Flag auf youtube_video_syncs:

FeldWertEffekt
social_profiles.pro_enabledfalsePRO-Badge weg, kein Sync mehr
social_profiles.pro_auto_activation_blocked_atnow()Lock — auto-activate-pro-Cron skippt das Profil
social_profiles.extended_stats_tieroffTier auf Off
social_profiles.extended_stats_reasonadmin_disabledAudit-Marker
social_profiles.extended_stats_lockedtrueLock-Flag (zusaetzlich zum Datum)
social_profiles.extended_stats_reviewed_atnow()Review-Zeit
social_profiles.extended_stats_review_duenullKein Re-Review noetig
youtube_video_syncs.auto_sync_enabledfalseSync-Engine ueberspringt

Variante A: Nach Sprache UND Land​

Beispiel: Profile deaktivieren, deren effective_language = 'es' UND effective_country = 'ES' (spanische Profile aus Spanien).

Schritt 1+2 — Vorschau:

php artisan tinker --execute="\$language = 'es'; \$country = 'ES'; \$matches = \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('pro_enabled', true)->whereRaw('COALESCE(language, detected_language) = ?', [\$language])->whereRaw('COALESCE(country, detected_country) = ?', [\$country])->get(['id', 'title', 'handle', 'language', 'detected_language', 'country', 'detected_country']); echo 'Gefundene PRO-Profile (lang=' . \$language . ', country=' . \$country . '): ' . \$matches->count() . chr(10); \$matches->take(20)->each(function(\$p) { echo \$p->id . ': @' . \$p->handle . ' — ' . \$p->title . ' (lang=' . (\$p->language ?? \$p->detected_language) . ', country=' . (\$p->country ?? \$p->detected_country) . ')' . chr(10); });"

Schritt 3 — Deaktivierung:

php artisan tinker --execute="\$language = 'es'; \$country = 'ES'; \$count = 0; \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('pro_enabled', true)->whereRaw('COALESCE(language, detected_language) = ?', [\$language])->whereRaw('COALESCE(country, detected_country) = ?', [\$country])->each(function(\$profile) use (&\$count) { \$previousTier = \$profile->extended_stats_tier; \DB::transaction(function () use (\$profile) { \$profile->forceFill(['pro_enabled' => false, 'pro_auto_activation_blocked_at' => now(), 'extended_stats_tier' => \App\Enums\VideoStatsTier::Off->value, 'extended_stats_reason' => 'admin_disabled', 'extended_stats_locked' => true, 'extended_stats_reviewed_at' => now(), 'extended_stats_review_due' => null])->save(); \App\Models\YouTubeVideoSync::query()->where('social_profile_id', \$profile->id)->update(['auto_sync_enabled' => false]); }); \App\Events\VideoStatsTierChanged::dispatch(\$profile, \$previousTier, \App\Enums\VideoStatsTier::Off, 'admin_disabled', 'admin'); \$count++; }); echo 'PRO + Auto-Sync deaktiviert fuer ' . \$count . ' Profile.' . chr(10);"

Variante B: Nach Sprache ODER Land (eines von beiden)​

Beispiel: deutsch (de) oder Land Schweiz (CH) — eines reicht zum Match.

Vorschau:

php artisan tinker --execute="\$language = 'de'; \$country = 'CH'; \$matches = \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('pro_enabled', true)->where(function(\$q) use (\$language, \$country) { \$q->whereRaw('COALESCE(language, detected_language) = ?', [\$language])->orWhereRaw('COALESCE(country, detected_country) = ?', [\$country]); })->get(['id', 'title', 'handle', 'language', 'detected_language', 'country', 'detected_country']); echo 'Gefundene PRO-Profile (lang=' . \$language . ' OR country=' . \$country . '): ' . \$matches->count() . chr(10); \$matches->take(20)->each(function(\$p) { echo \$p->id . ': @' . \$p->handle . ' — ' . \$p->title . ' (lang=' . (\$p->language ?? \$p->detected_language) . ', country=' . (\$p->country ?? \$p->detected_country) . ')' . chr(10); });"

Deaktivierung:

php artisan tinker --execute="\$language = 'de'; \$country = 'CH'; \$count = 0; \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('pro_enabled', true)->where(function(\$q) use (\$language, \$country) { \$q->whereRaw('COALESCE(language, detected_language) = ?', [\$language])->orWhereRaw('COALESCE(country, detected_country) = ?', [\$country]); })->each(function(\$profile) use (&\$count) { \$previousTier = \$profile->extended_stats_tier; \DB::transaction(function () use (\$profile) { \$profile->forceFill(['pro_enabled' => false, 'pro_auto_activation_blocked_at' => now(), 'extended_stats_tier' => \App\Enums\VideoStatsTier::Off->value, 'extended_stats_reason' => 'admin_disabled', 'extended_stats_locked' => true, 'extended_stats_reviewed_at' => now(), 'extended_stats_review_due' => null])->save(); \App\Models\YouTubeVideoSync::query()->where('social_profile_id', \$profile->id)->update(['auto_sync_enabled' => false]); }); \App\Events\VideoStatsTierChanged::dispatch(\$profile, \$previousTier, \App\Enums\VideoStatsTier::Off, 'admin_disabled', 'admin'); \$count++; }); echo 'PRO + Auto-Sync deaktiviert fuer ' . \$count . ' Profile.' . chr(10);"

Variante C: Nur nach Land​

Sprach-Filter weglassen, nur Land:

Vorschau:

php artisan tinker --execute="\$country = 'IN'; \$matches = \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('pro_enabled', true)->whereRaw('COALESCE(country, detected_country) = ?', [\$country])->get(['id', 'title', 'handle', 'country', 'detected_country']); echo 'Gefundene PRO-Profile (country=' . \$country . '): ' . \$matches->count() . chr(10); \$matches->take(20)->each(function(\$p) { echo \$p->id . ': @' . \$p->handle . ' — ' . \$p->title . ' (country=' . (\$p->country ?? \$p->detected_country) . ')' . chr(10); });"

Deaktivierung:

php artisan tinker --execute="\$country = 'IN'; \$count = 0; \App\Models\SocialProfile::query()->where('platform', 'youtube')->where('pro_enabled', true)->whereRaw('COALESCE(country, detected_country) = ?', [\$country])->each(function(\$profile) use (&\$count) { \$previousTier = \$profile->extended_stats_tier; \DB::transaction(function () use (\$profile) { \$profile->forceFill(['pro_enabled' => false, 'pro_auto_activation_blocked_at' => now(), 'extended_stats_tier' => \App\Enums\VideoStatsTier::Off->value, 'extended_stats_reason' => 'admin_disabled', 'extended_stats_locked' => true, 'extended_stats_reviewed_at' => now(), 'extended_stats_review_due' => null])->save(); \App\Models\YouTubeVideoSync::query()->where('social_profile_id', \$profile->id)->update(['auto_sync_enabled' => false]); }); \App\Events\VideoStatsTierChanged::dispatch(\$profile, \$previousTier, \App\Enums\VideoStatsTier::Off, 'admin_disabled', 'admin'); \$count++; }); echo 'PRO + Auto-Sync deaktiviert fuer ' . \$count . ' Profile.' . chr(10);"

Parameter​

ParameterBeschreibung
\$languageISO 639-1 (z.B. 'es', 'de', 'fr', 'en') — match gegen COALESCE(language, detected_language)
\$countryISO 3166-1 alpha-2 (z.B. 'ES', 'DE', 'CH', 'IN', 'US') — match gegen COALESCE(country, detected_country)
where('pro_enabled', true)Nur tatsaechlich PRO-aktivierte Profile — bereits-disable wird uebersprungen

Wichtige Hinweise​

  • WebSub-Unsubscribe ist NICHT enthalten — die Subscription bleibt am Hub aktiv, das Profil ignoriert nur kommende Notifications via Tier-Off-Check. Bei vielen Profilen waere ein WebSub-Unsubscribe pro Profil zu langsam (eine HTTPS-Roundtrip an Google pro Profil). Bei Bedarf separater Cleanup-Lauf via youtube:manage-websub.
  • Idempotent — wiederholtes Ausfuehren mit dem gleichen Filter setzt nur die wenigen verbleibenden Profile (Filter ist pro_enabled=true).
  • VideoStatsTierChanged-Event wird pro Profil dispatcht — der Listener HandleVideoStatsTierChange schreibt eine Benachrichtigung an alle Owner mit Watcher auf das Profil. Bei Massen-Deaktivierungen kann das tausende Notifications erzeugen — ggf. vorher \Illuminate\Support\Facades\Event::fake([\App\Events\VideoStatsTierChanged::class]) setzen, falls die Notifications nicht gewuenscht sind.
  • Vorschau ist Pflicht — vor Schritt 3 immer Schritt 1+2 ausfuehren und die Trefferzahl pruefen!
  • Re-Aktivierung manuell: Falls ein versehentlich deaktiviertes Profil wieder PRO werden soll, im Admin-UI auf /admin/social-profiles oder /admin/youtube-management/video-stats-tiers reaktivieren — das setzt pro_auto_activation_blocked_at = null.

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 prueft Subscription-Record, validiert HMAC-Signatur (mit Grace-Period), 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. Da jede Subscription ein Secret trägt, ist jede legitime Notification signiert — die Signatur ist daher Pflicht (eine komplett fehlende Signatur wird wie eine ungültige behandelt).

  • Kein Subscription-Record: Channel wurde abgemeldet (Cleanup nach disableProAndBlock etc.) — Notification wird still ignoriert (Log::info), 200 OK wird zurueckgegeben damit Google stoppt.
  • Fehlende Signatur (kein X-Hub-Signature Header): Notification wird abgelehnt — auch innerhalb der Grace-Period. Eine fehlende Signatur ist nie legitim; ohne diese Härtung würde ein gefälschter, unsignierter POST ungeprüft einen Fetch-Job auslösen (anonymer Endpoint, da Googles Hub ihn erreichen muss).
  • Ungueltige Signatur innerhalb der Grace-Period (30 min nach letztem Renewal): Notification wird akzeptiert (Log::info) — Google wechselt nach einem Re-Subscribe noch kurz mit dem alten Secret. (Gilt nur für eine vorhandene, mit dem alten Secret signierte Signatur — nicht für eine fehlende.)
  • Ungueltige Signatur ausserhalb der Grace-Period: Notification wird abgelehnt (Log::warning), 200 OK wird trotzdem zurueckgegeben (kein 403 — das wuerde Google zu Retry-Kaskaden veranlassen).

Channel-Cross-Check (Defense in Depth): FetchNewYouTubeVideo bindet das Video an die snippet.channelId aus der YouTube-API-Antwort, nicht an die in der Notification gemeldete channel_id. Eine Notification, die ein fremdes Video unter einem getrackten Kanal meldet, kann das Video so nicht unter dem Opfer-Profil ablegen (greift auch für die RSS-Detection).

Grace-Period-Basis: Die Grace-Period verwendet last_renewal_at (wird beim Start von subscribe()/renew() gesetzt), nicht subscribed_at (wird erst nach Hub-Verifikation gesetzt, waere bei Renewals tagelang alt und wuerde die Grace-Period wirkungslos machen).

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. Nur bei einer komplett neuen Subscription (kein bestehender Record) wird ein neuer Secret generiert.

Identity-Lookup (Plan 81): Die Tabelle websub_subscriptions hat zwei UNIQUE-Constraints (channel_id UND social_profile_id). subscribe() macht den Lookup deshalb in dieser Reihenfolge: zuerst by channel_id (echte Identity — ein YouTube-Kanal = eine Subscription), Fallback by social_profile_id. Stale-Rows (Channel zeigt auf anderes Profile, oder altes Profile-Row mit anderem Channel) werden in einer DB::transaction aufgeloest (Reassign / Delete), dann finaler updateOrCreate(['channel_id' => $channelId], $payload). Ohne diesen Pre-Cleanup crashte der INSERT bei Profile-Merges oder Channel-Reassignments mit SQLSTATE[23505] websub_subscriptions_channel_id_unique.

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.

Nicht abrufbare Videos (private/deleted/restricted)​

FetchNewYouTubeVideo behandelt zwei Failure-Modes ueber das gleiche Pattern (Log::info + return, kein retry, kein failed_jobs):

  • Private/Deleted: API liefert HTTP 200 mit leerem items[]-Array. Job loggt Video not found via API (private/deleted).
  • Region-/Age-restricted: API liefert HTTP 403 mit irrefuehrender Meldung The request cannot access user rating information ... myRating parameter. Wir senden den myRating-Parameter gar nicht — das ist eine API-Eigenart bei restricted Content ueber API-Key (ohne OAuth). Job loggt Video not accessible via API key (likely age/region-restricted). Erkennung: RuntimeException mit getCode() === 403 UND stripos($message, 'myRating') !== false.

In beiden Faellen bleibt der youtube_videos-Record unangelegt — ohne API-Zugriff koennen wir keine korrekten Metadaten persistieren. Folge-Snapshots (Daily-Sync) versuchen es nicht erneut, weil der Video-Record gar nicht existiert.

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