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..043cd6de
--- /dev/null
+++ b/app/Services/Social/YouTubeAnalytics.php
@@ -0,0 +1,128 @@
+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 [];
+ }
+
+ $json = $response->json();
+ $rows = data_get($json, 'rows', []);
+
+ if (empty($rows)) {
+ return [];
+ }
+
+ $columnHeaders = data_get($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..a84aff44
--- /dev/null
+++ b/resources/js/components/analytics/YouTubeAnalytics.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ metric.label }}
+
+ {{ formatNumber(metric.value) }}
+
+
+
+
+
+
+
+ {{ $t('analytics.no_data') }}
+
+
diff --git a/resources/js/pages/analytics/Index.vue b/resources/js/pages/analytics/Index.vue
index b267c946..b04cf6c5 100644
--- a/resources/js/pages/analytics/Index.vue
+++ b/resources/js/pages/analytics/Index.vue
@@ -11,6 +11,7 @@ import PinterestAnalytics from '@/components/analytics/PinterestAnalytics.vue';
import ThreadsAnalytics from '@/components/analytics/ThreadsAnalytics.vue';
import TikTokAnalytics from '@/components/analytics/TikTokAnalytics.vue';
import XAnalytics from '@/components/analytics/XAnalytics.vue';
+import YouTubeAnalytics from '@/components/analytics/YouTubeAnalytics.vue';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
@@ -104,6 +105,12 @@ const platformSupportsDateRange = computed(() => {
:date-range="dateRange"
/>
+
+
{{ $t('analytics.no_data') }}
diff --git a/tests/Feature/YouTubeAnalyticsTest.php b/tests/Feature/YouTubeAnalyticsTest.php
new file mode 100644
index 00000000..c5282027
--- /dev/null
+++ b/tests/Feature/YouTubeAnalyticsTest.php
@@ -0,0 +1,240 @@
+user = User::factory()->create(['setup' => Setup::Completed]);
+ $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
+
+ $this->youtubeAccount = SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::YouTube,
+ 'platform_user_id' => 'UC_test_channel_123',
+ 'username' => 'testchannel',
+ 'display_name' => 'Test Channel',
+ 'access_token' => 'ya29.test_access_token',
+ 'refresh_token' => 'refresh_token_123',
+ 'token_expires_at' => now()->addHours(2),
+ 'status' => AccountStatus::Connected,
+ 'is_active' => true,
+ 'meta' => [
+ 'channel_id' => 'UC_test_channel_123',
+ 'google_user_id' => 'google_user_123',
+ ],
+ ]);
+
+ $this->user->update(['current_workspace_id' => $this->workspace->id]);
+});
+
+test('youtube analytics returns metrics from api', function () {
+ Http::fake([
+ 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
+ 'columnHeaders' => [
+ ['name' => 'views'],
+ ['name' => 'estimatedMinutesWatched'],
+ ['name' => 'averageViewDuration'],
+ ['name' => 'averageViewPercentage'],
+ ['name' => 'subscribersGained'],
+ ['name' => 'subscribersLost'],
+ ['name' => 'likes'],
+ ],
+ 'rows' => [
+ [1500, 3200, 128, 45.5, 50, 5, 200],
+ ],
+ ], 200),
+ ]);
+
+ $analytics = app(YouTubeAnalytics::class);
+ $metrics = $analytics->getMetrics($this->youtubeAccount);
+
+ expect($metrics)->toBeArray()
+ ->and($metrics)->toHaveCount(7)
+ ->and($metrics[0])->toMatchArray(['label' => 'Views', 'value' => 1500])
+ ->and($metrics[1])->toMatchArray(['label' => 'Minutes Watched', 'value' => 3200])
+ ->and($metrics[2])->toMatchArray(['label' => 'Avg. View Duration (s)', 'value' => 128])
+ ->and($metrics[3])->toMatchArray(['label' => 'Avg. View Percentage', 'value' => 45.5])
+ ->and($metrics[4])->toMatchArray(['label' => 'Subscribers Gained', 'value' => 50])
+ ->and($metrics[5])->toMatchArray(['label' => 'Subscribers Lost', 'value' => 5])
+ ->and($metrics[6])->toMatchArray(['label' => 'Likes', 'value' => 200]);
+});
+
+test('youtube analytics returns empty array on api failure', function () {
+ Http::fake([
+ 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([], 403),
+ ]);
+
+ $analytics = app(YouTubeAnalytics::class);
+ $metrics = $analytics->getMetrics($this->youtubeAccount);
+
+ expect($metrics)->toBeArray()->toBeEmpty();
+});
+
+test('youtube analytics returns empty array when no rows', function () {
+ Http::fake([
+ 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
+ 'columnHeaders' => [
+ ['name' => 'views'],
+ ],
+ 'rows' => [],
+ ], 200),
+ ]);
+
+ $analytics = app(YouTubeAnalytics::class);
+ $metrics = $analytics->getMetrics($this->youtubeAccount);
+
+ expect($metrics)->toBeArray()->toBeEmpty();
+});
+
+test('youtube analytics caches results', function () {
+ Http::fake([
+ 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
+ 'columnHeaders' => [
+ ['name' => 'views'],
+ ],
+ 'rows' => [
+ [500],
+ ],
+ ], 200),
+ ]);
+
+ $analytics = app(YouTubeAnalytics::class);
+ $analytics->getMetrics($this->youtubeAccount);
+ $analytics->getMetrics($this->youtubeAccount);
+
+ Http::assertSentCount(1);
+});
+
+test('youtube analytics supports date range', function () {
+ Http::fake([
+ 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
+ 'columnHeaders' => [
+ ['name' => 'views'],
+ ],
+ 'rows' => [
+ [1000],
+ ],
+ ], 200),
+ ]);
+
+ $analytics = app(YouTubeAnalytics::class);
+ $metrics = $analytics->getMetrics(
+ $this->youtubeAccount,
+ now()->subDays(30),
+ now(),
+ );
+
+ expect($metrics)->toBeArray()->toHaveCount(1);
+
+ Http::assertSent(fn ($request) => str_contains($request->url(), 'startDate=')
+ && str_contains($request->url(), 'endDate=')
+ );
+});
+
+test('youtube analytics refreshes expired token', function () {
+ $this->youtubeAccount->update(['token_expires_at' => now()->subMinutes(5)]);
+
+ Http::fake([
+ 'https://oauth2.googleapis.com/token' => Http::response([
+ 'access_token' => 'new_access_token',
+ 'expires_in' => 3600,
+ ], 200),
+ 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
+ 'columnHeaders' => [
+ ['name' => 'views'],
+ ],
+ 'rows' => [
+ [100],
+ ],
+ ], 200),
+ ]);
+
+ $analytics = app(YouTubeAnalytics::class);
+ $metrics = $analytics->getMetrics($this->youtubeAccount);
+
+ expect($metrics)->toBeArray()->toHaveCount(1);
+
+ $this->youtubeAccount->refresh();
+ expect($this->youtubeAccount->access_token)->toBe('new_access_token');
+});
+
+test('youtube analytics throws exception when no refresh token', function () {
+ $this->youtubeAccount->update([
+ 'token_expires_at' => now()->subMinutes(5),
+ 'refresh_token' => null,
+ ]);
+
+ $analytics = app(YouTubeAnalytics::class);
+ $analytics->getMetrics($this->youtubeAccount);
+})->throws(TokenExpiredException::class);
+
+test('youtube analytics throws exception on token refresh failure', function () {
+ $this->youtubeAccount->update(['token_expires_at' => now()->subMinutes(5)]);
+
+ Http::fake([
+ 'https://oauth2.googleapis.com/token' => Http::response(['error' => 'invalid_grant'], 400),
+ ]);
+
+ $analytics = app(YouTubeAnalytics::class);
+ $analytics->getMetrics($this->youtubeAccount);
+})->throws(TokenExpiredException::class);
+
+test('youtube is in supported analytics platforms', function () {
+ config(['trypost.self_hosted' => true]);
+
+ $response = $this->actingAs($this->user)
+ ->get(route('app.analytics'));
+
+ $response->assertOk();
+
+ $accounts = $response->original->getData()['page']['props']['accounts'];
+ $youtubeAccount = collect($accounts)->firstWhere('platform', Platform::YouTube->value);
+
+ expect($youtubeAccount)->not->toBeNull()
+ ->and($youtubeAccount['id'])->toBe($this->youtubeAccount->id);
+});
+
+test('youtube analytics show endpoint returns metrics', function () {
+ config(['trypost.self_hosted' => true]);
+
+ Http::fake([
+ 'https://youtubeanalytics.googleapis.com/v2/reports*' => Http::response([
+ 'columnHeaders' => [
+ ['name' => 'views'],
+ ['name' => 'likes'],
+ ],
+ 'rows' => [
+ [500, 30],
+ ],
+ ], 200),
+ ]);
+
+ $response = $this->actingAs($this->user)
+ ->getJson(route('app.analytics.show', $this->youtubeAccount));
+
+ $response->assertOk()
+ ->assertJsonStructure(['metrics'])
+ ->assertJsonCount(2, 'metrics');
+});
+
+test('youtube analytics show endpoint rejects other workspace accounts', function () {
+ config(['trypost.self_hosted' => true]);
+
+ $otherUser = User::factory()->create(['setup' => Setup::Completed]);
+ $otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]);
+ $otherUser->update(['current_workspace_id' => $otherWorkspace->id]);
+
+ $response = $this->actingAs($otherUser)
+ ->getJson(route('app.analytics.show', $this->youtubeAccount));
+
+ $response->assertForbidden();
+});