diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php
index 8470e930..e3205698 100644
--- a/app/Http/Controllers/App/AnalyticsController.php
+++ b/app/Http/Controllers/App/AnalyticsController.php
@@ -14,6 +14,7 @@
use App\Services\Social\ThreadsAnalytics;
use App\Services\Social\TikTokAnalytics;
use App\Services\Social\XAnalytics;
+use App\Services\Social\YouTubeAnalytics;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -32,6 +33,7 @@ class AnalyticsController extends Controller
Platform::X,
Platform::LinkedInPage,
Platform::Pinterest,
+ Platform::YouTube,
];
public function index(Request $request): Response
@@ -74,6 +76,7 @@ public function show(Request $request, SocialAccount $account): JsonResponse
Platform::X => app(XAnalytics::class)->getMetrics($account, $since, $until),
Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->getMetrics($account, $since, $until),
Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until),
+ Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until),
default => [],
};
diff --git a/app/Http/Controllers/Auth/YouTubeController.php b/app/Http/Controllers/Auth/YouTubeController.php
index 6ec81df4..4786d536 100644
--- a/app/Http/Controllers/Auth/YouTubeController.php
+++ b/app/Http/Controllers/Auth/YouTubeController.php
@@ -26,6 +26,7 @@ class YouTubeController extends SocialController
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/youtube.force-ssl',
+ 'https://www.googleapis.com/auth/yt-analytics.readonly',
];
public function connect(Request $request): Response|RedirectResponse
diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php
new file mode 100644
index 00000000..8a9efc3d
--- /dev/null
+++ b/app/Services/Social/YouTubeAnalytics.php
@@ -0,0 +1,127 @@
+subDays(7);
+ $until ??= now();
+
+ $cacheKey = "analytics:youtube:{$account->id}:{$since->format('Y-m-d')}:{$until->format('Y-m-d')}";
+ $cacheTtl = app()->isProduction() ? 3600 : 1;
+
+ return Cache::remember($cacheKey, $cacheTtl, function () use ($account, $since, $until) {
+ return $this->fetchMetricsFromApi($account, $since, $until);
+ });
+ }
+
+ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
+ {
+ if ($account->is_token_expired || $account->is_token_expiring_soon) {
+ $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
+ $account->refresh();
+ }
+
+ $this->accessToken = $account->access_token;
+
+ $response = $this->getHttpClient()
+ ->get("{$this->baseUrl}/reports", [
+ 'ids' => 'channel==MINE',
+ 'startDate' => $since->format('Y-m-d'),
+ 'endDate' => $until->format('Y-m-d'),
+ 'metrics' => 'views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,subscribersGained,subscribersLost,likes',
+ ]);
+
+ if ($response->failed()) {
+ Log::warning('YouTube Analytics fetch failed', [
+ 'status' => $response->status(),
+ 'body' => $this->redactResponseBody($response->body()),
+ ]);
+
+ return [];
+ }
+
+ $rows = data_get($response->json(), 'rows', []);
+
+ if (empty($rows)) {
+ return [];
+ }
+
+ $columnHeaders = data_get($response->json(), 'columnHeaders', []);
+ $metricNames = collect($columnHeaders)->pluck('name')->toArray();
+ $values = data_get($rows, '0', []);
+
+ $metrics = [];
+
+ foreach ($metricNames as $index => $name) {
+ $value = data_get($values, $index, 0);
+
+ $label = match ($name) {
+ 'views' => 'Views',
+ 'estimatedMinutesWatched' => 'Minutes Watched',
+ 'averageViewDuration' => 'Avg. View Duration (s)',
+ 'averageViewPercentage' => 'Avg. View Percentage',
+ 'subscribersGained' => 'Subscribers Gained',
+ 'subscribersLost' => 'Subscribers Lost',
+ 'likes' => 'Likes',
+ default => ucfirst(str_replace('_', ' ', $name)),
+ };
+
+ $metrics[] = ['label' => $label, 'value' => round((float) $value, 1)];
+ }
+
+ return $metrics;
+ }
+
+ private function getHttpClient(): PendingRequest
+ {
+ return $this->socialHttp()->withToken($this->accessToken);
+ }
+
+ private function refreshToken(SocialAccount $account): void
+ {
+ if (! $account->refresh_token) {
+ throw new TokenExpiredException('No refresh token available for YouTube account');
+ }
+
+ $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
+ 'client_id' => config('services.google.client_id'),
+ 'client_secret' => config('services.google.client_secret'),
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $account->refresh_token,
+ ]);
+
+ if ($response->failed()) {
+ Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
+
+ throw new TokenExpiredException('Failed to refresh YouTube token');
+ }
+
+ $data = $response->json();
+
+ $account->update([
+ 'access_token' => data_get($data, 'access_token'),
+ 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
+ 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
+ ]);
+ }
+}
diff --git a/resources/js/components/analytics/YouTubeAnalytics.vue b/resources/js/components/analytics/YouTubeAnalytics.vue
new file mode 100644
index 00000000..c57a4a4b
--- /dev/null
+++ b/resources/js/components/analytics/YouTubeAnalytics.vue
@@ -0,0 +1,87 @@
+
+
+
+
+ {{ metric.label }}
+ {{ formatNumber(metric.value) }}
+