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
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
| Ort | Zweck |
|---|---|
config/postbox.php → youtube_view_counting.change_date | der Stichtag selbst (ISO-Datum) |
App\Support\YouTube\ViewCountingChange | einziger Ort, der ihn interpretiert |
App\Support\Charts\ViewCountingAnnotation | Markierung des Bruchs in Zeitreihen-Diagrammen |
ViewCountingChange kennt drei Zählweisen, nicht zwei:
legacy— mit Mindestwiedergabezeit, bis einschliesslich 23.08.2026playback_start— ab dem ersten Frame, ab 24.08.2026mixed— 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
| Bereich | Betroffen? | Behandlung |
|---|---|---|
view_count in youtube_video_daily_metrics / social_profile_daily_metrics | Rohwert, unverändert korrekt | keine — die Werte werden nie umgerechnet |
| Diagramme mit Tagesdifferenzen | ja | Bruch wird am Stichtag eingezeichnet, plus erklärender Satz |
VideoScoreCalculator::calculateGrowthTrajectory() | ja | liefert den Neutralwert, solange das 30-Tage-Fenster den Stichtag überspannt |
| Veröffentlichte Benchmark-Zahlen | ja | jede Aggregat-Zeile trägt ihre Zählweise als Spalte |
| Anomalie-Erkennung | nein — geprüft, nicht angenommen | keine; der Detektor liest den Bestand, und der springt nicht |
| Likes, Kommentare, Abonnenten, Videoanzahl | nein | keine |
| Quota-Kosten der API | nein | keine |
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:
| 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.
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 Commandyoutube:repair-pro-syncextrahiert 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):
pro_enabled = truesetzen fuer Profile mitfollowers_count >= YOUTUBE_PRO_MIN_FOLLOWERSUNDpro_auto_activation_blocked_at IS NULLYouTubeVideoSync-Eintrag erstellen mitauto_sync_enabled = true+video_tracking_start_date = today+requested_by = NULL(markiert auto-Aktivierung, unterscheidbar von manuellerrequested_by = $user->id)- Initialen
SyncYouTubeVideoStats-Job dispatchen (nur wenn noch keine Videos vorhanden,triggeredManually=truebypasst Tier+Bucket-Check) 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 = trueUND keinemyoutube_video_syncs-Eintrag - Profile mit
pro_enabled = trueUNDauto_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:
YouTubeVideoSync-Eintrag setzen/erstellen mitauto_sync_enabled = true,requested_by = NULL,video_tracking_start_date = today(falls null)- Initialer
SyncYouTubeVideoStats-Job dispatchen (falls noch keine Videos da) 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_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
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:
| Feld | Wert | Effekt |
|---|---|---|
social_profiles.pro_enabled | false | PRO-Badge weg, kein Sync mehr |
social_profiles.pro_auto_activation_blocked_at | now() | Lock — auto-activate-pro-Cron skippt das Profil |
social_profiles.extended_stats_tier | off | Tier auf Off |
social_profiles.extended_stats_reason | admin_disabled | Audit-Marker |
social_profiles.extended_stats_locked | true | Lock-Flag (zusaetzlich zum Datum) |
social_profiles.extended_stats_reviewed_at | now() | Review-Zeit |
social_profiles.extended_stats_review_due | null | Kein Re-Review noetig |
youtube_video_syncs.auto_sync_enabled | false | Sync-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
| Parameter | Beschreibung |
|---|---|
\$language | ISO 639-1 (z.B. 'es', 'de', 'fr', 'en') — match gegen COALESCE(language, detected_language) |
\$country | ISO 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
HandleVideoStatsTierChangeschreibt 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-profilesoder/admin/youtube-management/video-stats-tiersreaktivieren — das setztpro_auto_activation_blocked_at = null.
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 prueft Subscription-Record, validiert HMAC-Signatur (mit Grace-Period), 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. 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
disableProAndBlocketc.) — Notification wird still ignoriert (Log::info), 200 OK wird zurueckgegeben damit Google stoppt. - Fehlende Signatur (kein
X-Hub-SignatureHeader): 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 loggtVideo 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 denmyRating-Parameter gar nicht — das ist eine API-Eigenart bei restricted Content ueber API-Key (ohne OAuth). Job loggtVideo not accessible via API key (likely age/region-restricted).Erkennung:RuntimeExceptionmitgetCode() === 403UNDstripos($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
| 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 |