Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/Http/Controllers/App/AnalyticsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +33,7 @@ class AnalyticsController extends Controller
Platform::X,
Platform::LinkedInPage,
Platform::Pinterest,
Platform::YouTube,
];

public function index(Request $request): Response
Expand Down Expand Up @@ -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 => [],
};

Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/Auth/YouTubeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions app/Services/Social/YouTubeAnalytics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?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 [];
}

$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,
]);
}
}
87 changes: 87 additions & 0 deletions resources/js/components/analytics/YouTubeAnalytics.vue
Original file line number Diff line number Diff line change
@@ -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 8" :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>
7 changes: 7 additions & 0 deletions resources/js/pages/analytics/Index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
Expand Down
Loading
Loading