feat(youtube): add VideoMetricsSyncService for batch metrics sync

Syncs video performance metrics (views, likes, comments) from YouTube
API to YouTubeContent.performance fields. Supports batch processing
with 50-video API limit, credential validation, and per-batch error
handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-14 13:25:12 +00:00
parent b52e668ecb
commit 5ddcd5ab45
2 changed files with 539 additions and 0 deletions

View file

@ -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<MetricsSyncResult> {
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<T>(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 }

View file

@ -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<string, unknown>): 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),
}),
}),
}),
)
})
})