mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
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:
parent
b52e668ecb
commit
5ddcd5ab45
2 changed files with 539 additions and 0 deletions
182
src/lib/integrations/youtube/VideoMetricsSyncService.ts
Normal file
182
src/lib/integrations/youtube/VideoMetricsSyncService.ts
Normal 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 }
|
||||
357
tests/unit/youtube/video-metrics-sync.unit.spec.ts
Normal file
357
tests/unit/youtube/video-metrics-sync.unit.spec.ts
Normal 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),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue