Zum Hauptinhalt springen

YouTube Pipeline

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

Channel Import

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

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

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

Daily Scrape

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

Rotation-Bucket-System

Profile werden deterministisch auf Rotation-Buckets verteilt:

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

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

Priority-Profile (taeglicher Scrape)

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

Ablauf pro Profil

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

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

Location: app/Console/Commands/SocialScrapeDailyFollowers.php

Playlist-404 Handling (Video Sync)

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

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

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

Location: app/Jobs/YouTube/SyncYouTubeVideoStats.php

Error-Handling (SyncYouTubeVideoStats)

Der Video Sync Job hat differenziertes Error-Handling:

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

Video Sync

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

Manueller Trigger

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

Auto-Sync (naechtlicher Delta-Sync)

Steuerung ueber youtube_video_syncs Tabelle:

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

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

Location: app/Jobs/YouTube/SyncYouTubeVideoStats.php

Automatische Erkennung verwandter YouTube-Kanaele per API-Suche.

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

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

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

Description Parsing

ProfileDescriptionParser analysiert Bio-Texte und extrahiert strukturierte Daten.

Extrahierte Typen

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

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

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

Location: app/Services/Social/ProfileDescriptionParser.php

API Parts

Der Channel-Request nutzt folgende YouTube Data API Parts:

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

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

PRO Aktivierung

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

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

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.

  • Kein Subscription-Record: Channel wurde abgemeldet (Cleanup nach disableProAndBlock etc.) — Notification wird still ignoriert (Log::info), 200 OK wird zurueckgegeben damit Google stoppt.
  • 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.
  • 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).

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