diff --git a/src/lib/integrations/youtube/VideoMetricsSyncService.ts b/src/lib/integrations/youtube/VideoMetricsSyncService.ts new file mode 100644 index 0000000..dcd7742 --- /dev/null +++ b/src/lib/integrations/youtube/VideoMetricsSyncService.ts @@ -0,0 +1,182 @@ +// src/lib/integrations/youtube/VideoMetricsSyncService.ts + +import type { Payload } from 'payload' + +import { YouTubeClient } from './YouTubeClient.js' + +const YOUTUBE_API_BATCH_LIMIT = 50 + +interface MetricsSyncResult { + success: boolean + syncedCount: number + skippedCount: number + errors: string[] + syncedAt: Date +} + +/** + * Syncs video performance metrics from YouTube API to YouTubeContent.performance fields. + * + * Fetches view counts, like counts, and comment counts for all published/tracked + * videos of a given channel and updates their performance fields in the database. + */ +export class VideoMetricsSyncService { + private payload: Payload + + constructor(payload: Payload) { + this.payload = payload + } + + /** + * Sync metrics for all published/tracked videos of a YouTube channel. + */ + async syncMetrics(channelId: number): Promise { + const result: MetricsSyncResult = { + success: false, + syncedCount: 0, + skippedCount: 0, + errors: [], + syncedAt: new Date(), + } + + try { + // 1. Load the YouTube channel + const channel = await this.payload.findByID({ + collection: 'youtube-channels', + id: channelId, + }) + + if (!channel) { + result.errors.push(`YouTube channel with ID ${channelId} not found`) + return result + } + + // 2. Find the social account linked to this channel + const socialAccounts = await this.payload.find({ + collection: 'social-accounts', + where: { + linkedChannel: { equals: channelId }, + isActive: { equals: true }, + }, + limit: 1, + }) + + const account = socialAccounts.docs[0] + if (!account) { + result.errors.push(`No active social account found for channel ${channelId}`) + return result + } + + // 3. Validate credentials + const credentials = account.credentials as { + accessToken?: string + refreshToken?: string + } | undefined + + if (!credentials?.accessToken || !credentials?.refreshToken) { + result.errors.push('No valid API credentials on social account') + return result + } + + // 4. Initialize YouTube client + const youtubeClient = new YouTubeClient( + { + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + }, + this.payload, + ) + + // 5. Find all published/tracked videos with a YouTube videoId + const videos = await this.payload.find({ + collection: 'youtube-content', + where: { + channel: { equals: channelId }, + status: { in: ['published', 'tracked'] }, + }, + limit: 0, + depth: 0, + }) + + const videosWithYouTubeId = videos.docs.filter( + (video: any) => video.youtube?.videoId, + ) + + result.skippedCount = videos.docs.length - videosWithYouTubeId.length + + if (videosWithYouTubeId.length === 0) { + result.success = true + return result + } + + // 6. Batch videos into groups of 50 and fetch statistics + const batches = createBatches(videosWithYouTubeId, YOUTUBE_API_BATCH_LIMIT) + + for (const batch of batches) { + try { + const videoIds = batch.map((video: any) => video.youtube.videoId as string) + const statistics = await youtubeClient.getVideoStatistics(videoIds) + + // Build a lookup map for quick access + const statsMap = new Map(statistics.map((s) => [s.id, s])) + + // 7. Update each video's performance fields + for (const video of batch) { + const youtubeVideoId = (video as any).youtube.videoId as string + const stats = statsMap.get(youtubeVideoId) + + if (!stats) { + result.errors.push( + `No statistics returned for video ${youtubeVideoId} (doc ${video.id})`, + ) + continue + } + + try { + await this.payload.update({ + collection: 'youtube-content', + id: video.id, + data: { + performance: { + views: stats.views, + likes: stats.likes, + comments: stats.comments, + lastSyncedAt: new Date().toISOString(), + }, + }, + }) + result.syncedCount++ + } catch (updateError) { + result.errors.push( + `Failed to update video ${video.id}: ${updateError}`, + ) + } + } + } catch (batchError) { + result.errors.push(`Batch API call failed: ${batchError}`) + } + } + + result.success = result.errors.length === 0 + } catch (error) { + result.errors.push(`Sync error: ${error}`) + } + + return result + } +} + +/** + * Split an array into chunks of a given size. + */ +function createBatches(items: T[], batchSize: number): T[][] { + const batches: T[][] = [] + for (let i = 0; i < items.length; i += batchSize) { + batches.push(items.slice(i, i + batchSize)) + } + return batches +} + +export type { MetricsSyncResult } diff --git a/tests/unit/youtube/video-metrics-sync.unit.spec.ts b/tests/unit/youtube/video-metrics-sync.unit.spec.ts new file mode 100644 index 0000000..07e8464 --- /dev/null +++ b/tests/unit/youtube/video-metrics-sync.unit.spec.ts @@ -0,0 +1,357 @@ +/** + * VideoMetricsSyncService Unit Tests + * + * Tests the service that syncs video performance metrics from YouTube API + * to YouTubeContent.performance fields in the database. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock YouTubeClient as a proper class before importing the service +const mockGetVideoStatistics = vi.fn() + +vi.mock('@/lib/integrations/youtube/YouTubeClient', () => { + return { + YouTubeClient: class MockYouTubeClient { + getVideoStatistics = mockGetVideoStatistics + }, + } +}) + +const mockFindByID = vi.fn() +const mockFind = vi.fn() +const mockUpdate = vi.fn() + +const mockPayload = { + findByID: mockFindByID, + find: mockFind, + update: mockUpdate, +} as unknown as import('payload').Payload + +describe('VideoMetricsSyncService', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function setupChannel(): void { + mockFindByID.mockResolvedValueOnce({ + id: 1, + name: 'BlogWoman', + youtubeChannelId: 'UCxxx', + }) + } + + function setupSocialAccount(overrides?: Record): void { + mockFind.mockResolvedValueOnce({ + docs: [ + { + id: 10, + credentials: { + accessToken: 'token-123', + refreshToken: 'refresh-456', + }, + isActive: true, + ...overrides, + }, + ], + totalDocs: 1, + }) + } + + function setupVideos( + videos: Array<{ id: number; youtube?: { videoId?: string }; status: string }>, + ): void { + mockFind.mockResolvedValueOnce({ + docs: videos, + totalDocs: videos.length, + }) + } + + it('should sync metrics for published videos with YouTube videoId', async () => { + setupChannel() + setupSocialAccount() + setupVideos([ + { id: 100, youtube: { videoId: 'yt-vid-1' }, status: 'published' }, + { id: 101, youtube: { videoId: 'yt-vid-2' }, status: 'tracked' }, + ]) + + mockGetVideoStatistics.mockResolvedValueOnce([ + { id: 'yt-vid-1', views: 1500, likes: 120, comments: 45 }, + { id: 'yt-vid-2', views: 3200, likes: 250, comments: 80 }, + ]) + + mockUpdate.mockResolvedValue({}) + + const { VideoMetricsSyncService } = await import( + '@/lib/integrations/youtube/VideoMetricsSyncService' + ) + + const service = new VideoMetricsSyncService(mockPayload) + const result = await service.syncMetrics(1) + + expect(result.success).toBe(true) + expect(result.syncedCount).toBe(2) + expect(result.skippedCount).toBe(0) + expect(result.errors).toHaveLength(0) + + expect(mockUpdate).toHaveBeenCalledTimes(2) + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'youtube-content', + id: 100, + data: expect.objectContaining({ + performance: expect.objectContaining({ + views: 1500, + likes: 120, + comments: 45, + }), + }), + }), + ) + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'youtube-content', + id: 101, + data: expect.objectContaining({ + performance: expect.objectContaining({ + views: 3200, + likes: 250, + comments: 80, + }), + }), + }), + ) + }) + + it('should skip videos without a YouTube videoId', async () => { + setupChannel() + setupSocialAccount() + setupVideos([ + { id: 100, youtube: { videoId: 'yt-vid-1' }, status: 'published' }, + { id: 101, youtube: {}, status: 'published' }, + { id: 102, status: 'published' }, + ]) + + mockGetVideoStatistics.mockResolvedValueOnce([ + { id: 'yt-vid-1', views: 500, likes: 40, comments: 10 }, + ]) + + mockUpdate.mockResolvedValue({}) + + const { VideoMetricsSyncService } = await import( + '@/lib/integrations/youtube/VideoMetricsSyncService' + ) + + const service = new VideoMetricsSyncService(mockPayload) + const result = await service.syncMetrics(1) + + expect(result.success).toBe(true) + expect(result.syncedCount).toBe(1) + expect(result.skippedCount).toBe(2) + expect(mockGetVideoStatistics).toHaveBeenCalledWith(['yt-vid-1']) + }) + + it('should return early with success when no videos exist', async () => { + setupChannel() + setupSocialAccount() + setupVideos([]) + + const { VideoMetricsSyncService } = await import( + '@/lib/integrations/youtube/VideoMetricsSyncService' + ) + + const service = new VideoMetricsSyncService(mockPayload) + const result = await service.syncMetrics(1) + + expect(result.success).toBe(true) + expect(result.syncedCount).toBe(0) + expect(result.skippedCount).toBe(0) + expect(mockGetVideoStatistics).not.toHaveBeenCalled() + }) + + it('should return error when channel is not found', async () => { + mockFindByID.mockResolvedValueOnce(null) + + const { VideoMetricsSyncService } = await import( + '@/lib/integrations/youtube/VideoMetricsSyncService' + ) + + const service = new VideoMetricsSyncService(mockPayload) + const result = await service.syncMetrics(999) + + expect(result.success).toBe(false) + expect(result.errors).toContainEqual( + expect.stringContaining('not found'), + ) + expect(mockFind).not.toHaveBeenCalled() + }) + + it('should return error when no active social account exists', async () => { + setupChannel() + mockFind.mockResolvedValueOnce({ docs: [], totalDocs: 0 }) + + const { VideoMetricsSyncService } = await import( + '@/lib/integrations/youtube/VideoMetricsSyncService' + ) + + const service = new VideoMetricsSyncService(mockPayload) + const result = await service.syncMetrics(1) + + expect(result.success).toBe(false) + expect(result.errors).toContainEqual( + expect.stringContaining('No active social account'), + ) + }) + + it('should return error when credentials are missing', async () => { + setupChannel() + setupSocialAccount({ credentials: {} }) + + const { VideoMetricsSyncService } = await import( + '@/lib/integrations/youtube/VideoMetricsSyncService' + ) + + const service = new VideoMetricsSyncService(mockPayload) + const result = await service.syncMetrics(1) + + expect(result.success).toBe(false) + expect(result.errors).toContainEqual( + expect.stringContaining('No valid API credentials'), + ) + }) + + it('should batch videos into groups of 50', async () => { + setupChannel() + setupSocialAccount() + + // Create 75 videos to force 2 batches (50 + 25) + const videos = Array.from({ length: 75 }, (_, i) => ({ + id: i + 1, + youtube: { videoId: `yt-vid-${i + 1}` }, + status: 'published', + })) + setupVideos(videos) + + // First batch: 50 videos + mockGetVideoStatistics.mockResolvedValueOnce( + Array.from({ length: 50 }, (_, i) => ({ + id: `yt-vid-${i + 1}`, + views: 100, + likes: 10, + comments: 5, + })), + ) + + // Second batch: 25 videos + mockGetVideoStatistics.mockResolvedValueOnce( + Array.from({ length: 25 }, (_, i) => ({ + id: `yt-vid-${i + 51}`, + views: 200, + likes: 20, + comments: 10, + })), + ) + + mockUpdate.mockResolvedValue({}) + + const { VideoMetricsSyncService } = await import( + '@/lib/integrations/youtube/VideoMetricsSyncService' + ) + + const service = new VideoMetricsSyncService(mockPayload) + const result = await service.syncMetrics(1) + + expect(result.success).toBe(true) + expect(result.syncedCount).toBe(75) + expect(mockGetVideoStatistics).toHaveBeenCalledTimes(2) + + const firstCallIds = mockGetVideoStatistics.mock.calls[0][0] as string[] + expect(firstCallIds).toHaveLength(50) + + const secondCallIds = mockGetVideoStatistics.mock.calls[1][0] as string[] + expect(secondCallIds).toHaveLength(25) + }) + + it('should record errors for videos missing from API response', async () => { + setupChannel() + setupSocialAccount() + setupVideos([ + { id: 100, youtube: { videoId: 'yt-vid-1' }, status: 'published' }, + { id: 101, youtube: { videoId: 'yt-vid-2' }, status: 'published' }, + ]) + + // Only return stats for one video + mockGetVideoStatistics.mockResolvedValueOnce([ + { id: 'yt-vid-1', views: 500, likes: 40, comments: 10 }, + ]) + + mockUpdate.mockResolvedValue({}) + + const { VideoMetricsSyncService } = await import( + '@/lib/integrations/youtube/VideoMetricsSyncService' + ) + + const service = new VideoMetricsSyncService(mockPayload) + const result = await service.syncMetrics(1) + + expect(result.success).toBe(false) + expect(result.syncedCount).toBe(1) + expect(result.errors).toContainEqual( + expect.stringContaining('No statistics returned for video yt-vid-2'), + ) + }) + + it('should handle API errors gracefully per batch', async () => { + setupChannel() + setupSocialAccount() + setupVideos([ + { id: 100, youtube: { videoId: 'yt-vid-1' }, status: 'published' }, + ]) + + mockGetVideoStatistics.mockRejectedValueOnce(new Error('API quota exceeded')) + + const { VideoMetricsSyncService } = await import( + '@/lib/integrations/youtube/VideoMetricsSyncService' + ) + + const service = new VideoMetricsSyncService(mockPayload) + const result = await service.syncMetrics(1) + + expect(result.success).toBe(false) + expect(result.syncedCount).toBe(0) + expect(result.errors).toContainEqual( + expect.stringContaining('Batch API call failed'), + ) + }) + + it('should set lastSyncedAt in performance data', async () => { + setupChannel() + setupSocialAccount() + setupVideos([ + { id: 100, youtube: { videoId: 'yt-vid-1' }, status: 'published' }, + ]) + + mockGetVideoStatistics.mockResolvedValueOnce([ + { id: 'yt-vid-1', views: 100, likes: 10, comments: 5 }, + ]) + + mockUpdate.mockResolvedValue({}) + + const { VideoMetricsSyncService } = await import( + '@/lib/integrations/youtube/VideoMetricsSyncService' + ) + + const service = new VideoMetricsSyncService(mockPayload) + await service.syncMetrics(1) + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + performance: expect.objectContaining({ + lastSyncedAt: expect.any(String), + }), + }), + }), + ) + }) +})