feat: add YouTube Analytics with 7 channel metrics
Integrates YouTube Analytics API v2 to display channel-level metrics (views, minutes watched, avg view duration, avg view percentage, subscribers gained/lost, likes) with date range support and caching.
This commit is contained in:
parent
e805a3bdb3
commit
4cf601e618
6 changed files with 443 additions and 0 deletions
|
|
@ -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 => [],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
127
app/Services/Social/YouTubeAnalytics.php
Normal file
127
app/Services/Social/YouTubeAnalytics.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\Concerns\HasSocialHttpClient;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class YouTubeAnalytics
|
||||
{
|
||||
use HasSocialHttpClient;
|
||||
|
||||
private string $baseUrl = 'https://youtubeanalytics.googleapis.com/v2';
|
||||
|
||||
private string $accessToken;
|
||||
|
||||
public function getMetrics(SocialAccount $account, ?CarbonInterface $since = null, ?CarbonInterface $until = null): array
|
||||
{
|
||||
$since ??= now()->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
87
resources/js/components/analytics/YouTubeAnalytics.vue
Normal file
87
resources/js/components/analytics/YouTubeAnalytics.vue
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<script setup lang="ts">
|
||||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import dayjs from '@/dayjs';
|
||||
import { formatNumber } from '@/lib/utils';
|
||||
import { show as showAnalytics } from '@/routes/app/analytics';
|
||||
|
||||
interface MetricItem {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
accountId: string;
|
||||
dateRange: { start: Date; end: Date };
|
||||
}>();
|
||||
|
||||
const metrics = ref<MetricItem[]>([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const http = useHttp<Record<string, never>, { metrics: MetricItem[] }>({});
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
isLoading.value = true;
|
||||
metrics.value = [];
|
||||
|
||||
try {
|
||||
const response = await http.get(showAnalytics.url(props.accountId, {
|
||||
query: {
|
||||
since: dayjs(props.dateRange.start).format('YYYY-MM-DD'),
|
||||
until: dayjs(props.dateRange.end).format('YYYY-MM-DD'),
|
||||
},
|
||||
}));
|
||||
metrics.value = response?.metrics || [];
|
||||
} catch {
|
||||
metrics.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.accountId, () => {
|
||||
fetchMetrics();
|
||||
});
|
||||
|
||||
watch(() => props.dateRange, () => {
|
||||
fetchMetrics();
|
||||
}, { deep: true });
|
||||
|
||||
onMounted(() => {
|
||||
fetchMetrics();
|
||||
});
|
||||
|
||||
defineExpose({ supportsDateRange: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="i in 7" :key="i">
|
||||
<CardContent class="p-6">
|
||||
<Skeleton class="mb-3 h-4 w-24" />
|
||||
<Skeleton class="h-8 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Metrics -->
|
||||
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="metric in metrics" :key="metric.label">
|
||||
<CardContent class="p-6">
|
||||
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
|
||||
<p class="mt-2 text-3xl font-bold tracking-tight">
|
||||
{{ formatNumber(metric.value) }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- No Data -->
|
||||
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
|
||||
{{ $t('analytics.no_data') }}
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
<YouTubeAnalytics
|
||||
v-else-if="selectedAccount?.platform === 'youtube'"
|
||||
:account-id="selectedAccountId"
|
||||
:date-range="dateRange"
|
||||
/>
|
||||
|
||||
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
|
||||
{{ $t('analytics.no_data') }}
|
||||
</div>
|
||||
|
|
|
|||
218
tests/Feature/YouTubeAnalyticsTest.php
Normal file
218
tests/Feature/YouTubeAnalyticsTest.php
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\SocialAccount\Status as AccountStatus;
|
||||
use App\Enums\User\Setup;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\YouTubeAnalytics;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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 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();
|
||||
});
|
||||
Loading…
Reference in a new issue