mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
feat(youtube): add VideoUploadService
Implements a service that uploads videos to YouTube via the Data API v3. Resolves OAuth credentials from social-accounts, reads media files from disk, and handles scheduled publishes by setting privacyStatus to private with a publishAt timestamp. Includes 12 unit tests covering successful uploads, scheduled publishing, credential/media validation, and API errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fb4d5a8fe5
commit
6cc3011804
2 changed files with 612 additions and 0 deletions
194
src/lib/integrations/youtube/VideoUploadService.ts
Normal file
194
src/lib/integrations/youtube/VideoUploadService.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// src/lib/integrations/youtube/VideoUploadService.ts
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { google } from 'googleapis'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
interface VideoMetadata {
|
||||
title: string
|
||||
description: string
|
||||
tags: string[]
|
||||
visibility: 'public' | 'unlisted' | 'private'
|
||||
categoryId?: string
|
||||
}
|
||||
|
||||
interface UploadOptions {
|
||||
mediaId: number
|
||||
metadata: VideoMetadata
|
||||
scheduledPublishAt?: string
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
success: boolean
|
||||
youtubeVideoId?: string
|
||||
youtubeUrl?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads videos to YouTube via the Data API v3.
|
||||
*
|
||||
* Reads video files from the Payload media collection, resolves OAuth credentials
|
||||
* from the social-accounts collection, and calls youtube.videos.insert().
|
||||
*/
|
||||
export class VideoUploadService {
|
||||
private payload: Payload
|
||||
|
||||
constructor(payload: Payload) {
|
||||
this.payload = payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a video file from the media collection to YouTube.
|
||||
*
|
||||
* When scheduledPublishAt is provided, the video is uploaded as private
|
||||
* with a publishAt timestamp so YouTube publishes it automatically.
|
||||
*/
|
||||
async uploadVideo(options: UploadOptions): Promise<UploadResult> {
|
||||
const { mediaId, metadata, scheduledPublishAt } = options
|
||||
|
||||
// 1. Resolve OAuth credentials from social-accounts
|
||||
const credentials = await this.loadCredentials()
|
||||
if (!credentials) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No active YouTube social account with valid credentials found',
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Load the media document
|
||||
const media = await this.payload.findByID({
|
||||
collection: 'media',
|
||||
id: mediaId,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
if (!media) {
|
||||
return { success: false, error: `Media with ID ${mediaId} not found` }
|
||||
}
|
||||
|
||||
const filename = media.filename as string | undefined
|
||||
if (!filename) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Media ${mediaId} has no filename`,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verify the file exists on disk
|
||||
const filePath = path.join(process.cwd(), 'media', filename)
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `File not found on disk: ${filePath}`,
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Build the OAuth2 client
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
)
|
||||
oauth2Client.setCredentials({
|
||||
access_token: credentials.accessToken,
|
||||
refresh_token: credentials.refreshToken,
|
||||
})
|
||||
|
||||
const youtube = google.youtube({ version: 'v3', auth: oauth2Client })
|
||||
|
||||
// 5. Determine privacy status and optional scheduled publish time
|
||||
let privacyStatus: string = metadata.visibility
|
||||
let publishAt: string | undefined
|
||||
|
||||
if (scheduledPublishAt) {
|
||||
privacyStatus = 'private'
|
||||
publishAt = scheduledPublishAt
|
||||
}
|
||||
|
||||
// 6. Execute the upload
|
||||
try {
|
||||
const response = await youtube.videos.insert({
|
||||
part: ['snippet', 'status'],
|
||||
requestBody: {
|
||||
snippet: {
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
tags: metadata.tags,
|
||||
categoryId: metadata.categoryId,
|
||||
},
|
||||
status: {
|
||||
privacyStatus,
|
||||
...(publishAt ? { publishAt } : {}),
|
||||
},
|
||||
},
|
||||
media: {
|
||||
body: fs.createReadStream(filePath),
|
||||
},
|
||||
})
|
||||
|
||||
const videoId = response.data.id
|
||||
if (!videoId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'YouTube API returned no video ID',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
youtubeVideoId: videoId,
|
||||
youtubeUrl: `https://www.youtube.com/watch?v=${videoId}`,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: `YouTube upload failed: ${message}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first active YouTube social account with valid credentials.
|
||||
*/
|
||||
private async loadCredentials(): Promise<{
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
} | null> {
|
||||
const platforms = await this.payload.find({
|
||||
collection: 'social-platforms',
|
||||
where: { slug: { equals: 'youtube' } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
const platformId = platforms.docs[0]?.id
|
||||
if (!platformId) return null
|
||||
|
||||
const accounts = await this.payload.find({
|
||||
collection: 'social-accounts',
|
||||
where: {
|
||||
platform: { equals: platformId },
|
||||
isActive: { equals: true },
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
const account = accounts.docs[0]
|
||||
if (!account) return null
|
||||
|
||||
const creds = account.credentials as {
|
||||
accessToken?: string
|
||||
refreshToken?: string
|
||||
} | undefined
|
||||
|
||||
if (!creds?.accessToken || !creds?.refreshToken) return null
|
||||
|
||||
return {
|
||||
accessToken: creds.accessToken,
|
||||
refreshToken: creds.refreshToken,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { VideoMetadata, UploadOptions, UploadResult }
|
||||
418
tests/unit/youtube/video-upload-service.unit.spec.ts
Normal file
418
tests/unit/youtube/video-upload-service.unit.spec.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
/**
|
||||
* VideoUploadService Unit Tests
|
||||
*
|
||||
* Tests the service that uploads videos to YouTube via the Data API v3.
|
||||
* Validates credential resolution, media file handling, and YouTube API interaction.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
const mockVideosInsert = vi.fn()
|
||||
|
||||
vi.mock('googleapis', () => {
|
||||
class MockOAuth2 {
|
||||
setCredentials(): void {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
google: {
|
||||
auth: {
|
||||
OAuth2: MockOAuth2,
|
||||
},
|
||||
youtube: () => ({
|
||||
videos: {
|
||||
insert: mockVideosInsert,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mockExistsSync = vi.fn()
|
||||
const mockCreateReadStream = vi.fn()
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
createReadStream: (...args: unknown[]) => mockCreateReadStream(...args),
|
||||
},
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
createReadStream: (...args: unknown[]) => mockCreateReadStream(...args),
|
||||
}))
|
||||
|
||||
const mockFindByID = vi.fn()
|
||||
const mockFind = vi.fn()
|
||||
|
||||
const mockPayload = {
|
||||
findByID: mockFindByID,
|
||||
find: mockFind,
|
||||
} as unknown as import('payload').Payload
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function setupYouTubePlatform(): void {
|
||||
mockFind.mockResolvedValueOnce({
|
||||
docs: [{ id: 100, slug: 'youtube' }],
|
||||
totalDocs: 1,
|
||||
})
|
||||
}
|
||||
|
||||
function setupSocialAccount(overrides?: Record<string, unknown>): void {
|
||||
mockFind.mockResolvedValueOnce({
|
||||
docs: [
|
||||
{
|
||||
id: 10,
|
||||
isActive: true,
|
||||
credentials: {
|
||||
accessToken: 'yt-access-token',
|
||||
refreshToken: 'yt-refresh-token',
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
],
|
||||
totalDocs: 1,
|
||||
})
|
||||
}
|
||||
|
||||
function setupMedia(overrides?: Record<string, unknown>): void {
|
||||
mockFindByID.mockResolvedValueOnce({
|
||||
id: 1,
|
||||
filename: 'test-video.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
const defaultMetadata = {
|
||||
title: 'My Test Video',
|
||||
description: 'A great video about testing',
|
||||
tags: ['test', 'video'],
|
||||
visibility: 'public' as const,
|
||||
categoryId: '22',
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('VideoUploadService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.GOOGLE_CLIENT_ID = 'test-client-id'
|
||||
process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret'
|
||||
})
|
||||
|
||||
it('should upload a video and return the YouTube video ID', async () => {
|
||||
setupYouTubePlatform()
|
||||
setupSocialAccount()
|
||||
setupMedia()
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||
|
||||
mockVideosInsert.mockResolvedValueOnce({
|
||||
data: { id: 'yt-abc123' },
|
||||
})
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 1,
|
||||
metadata: defaultMetadata,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.youtubeVideoId).toBe('yt-abc123')
|
||||
expect(result.youtubeUrl).toBe('https://www.youtube.com/watch?v=yt-abc123')
|
||||
expect(result.error).toBeUndefined()
|
||||
|
||||
expect(mockVideosInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
part: ['snippet', 'status'],
|
||||
requestBody: expect.objectContaining({
|
||||
snippet: {
|
||||
title: 'My Test Video',
|
||||
description: 'A great video about testing',
|
||||
tags: ['test', 'video'],
|
||||
categoryId: '22',
|
||||
},
|
||||
status: {
|
||||
privacyStatus: 'public',
|
||||
},
|
||||
}),
|
||||
media: { body: 'fake-stream' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should set privacyStatus to private and include publishAt for scheduled uploads', async () => {
|
||||
setupYouTubePlatform()
|
||||
setupSocialAccount()
|
||||
setupMedia()
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||
|
||||
mockVideosInsert.mockResolvedValueOnce({
|
||||
data: { id: 'yt-scheduled-1' },
|
||||
})
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 1,
|
||||
metadata: { ...defaultMetadata, visibility: 'public' },
|
||||
scheduledPublishAt: '2026-03-01T12:00:00Z',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.youtubeVideoId).toBe('yt-scheduled-1')
|
||||
|
||||
expect(mockVideosInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestBody: expect.objectContaining({
|
||||
status: {
|
||||
privacyStatus: 'private',
|
||||
publishAt: '2026-03-01T12:00:00Z',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error when no YouTube social account exists', async () => {
|
||||
// Platform found, but no account
|
||||
setupYouTubePlatform()
|
||||
mockFind.mockResolvedValueOnce({ docs: [], totalDocs: 0 })
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 1,
|
||||
metadata: defaultMetadata,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('No active YouTube social account')
|
||||
expect(mockFindByID).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return error when social account has no credentials', async () => {
|
||||
setupYouTubePlatform()
|
||||
setupSocialAccount({ credentials: {} })
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 1,
|
||||
metadata: defaultMetadata,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('No active YouTube social account')
|
||||
expect(mockFindByID).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return error when YouTube platform is not found', async () => {
|
||||
mockFind.mockResolvedValueOnce({ docs: [], totalDocs: 0 })
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 1,
|
||||
metadata: defaultMetadata,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('No active YouTube social account')
|
||||
})
|
||||
|
||||
it('should return error when media document is not found', async () => {
|
||||
setupYouTubePlatform()
|
||||
setupSocialAccount()
|
||||
mockFindByID.mockResolvedValueOnce(null)
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 999,
|
||||
metadata: defaultMetadata,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Media with ID 999 not found')
|
||||
})
|
||||
|
||||
it('should return error when media has no filename', async () => {
|
||||
setupYouTubePlatform()
|
||||
setupSocialAccount()
|
||||
setupMedia({ filename: undefined })
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 1,
|
||||
metadata: defaultMetadata,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('has no filename')
|
||||
})
|
||||
|
||||
it('should return error when file does not exist on disk', async () => {
|
||||
setupYouTubePlatform()
|
||||
setupSocialAccount()
|
||||
setupMedia()
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 1,
|
||||
metadata: defaultMetadata,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('File not found on disk')
|
||||
expect(result.error).toContain('test-video.mp4')
|
||||
})
|
||||
|
||||
it('should return error when YouTube API returns no video ID', async () => {
|
||||
setupYouTubePlatform()
|
||||
setupSocialAccount()
|
||||
setupMedia()
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||
|
||||
mockVideosInsert.mockResolvedValueOnce({
|
||||
data: { id: null },
|
||||
})
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 1,
|
||||
metadata: defaultMetadata,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('YouTube API returned no video ID')
|
||||
})
|
||||
|
||||
it('should return error when YouTube API throws', async () => {
|
||||
setupYouTubePlatform()
|
||||
setupSocialAccount()
|
||||
setupMedia()
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||
|
||||
mockVideosInsert.mockRejectedValueOnce(new Error('quotaExceeded'))
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 1,
|
||||
metadata: defaultMetadata,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('YouTube upload failed')
|
||||
expect(result.error).toContain('quotaExceeded')
|
||||
})
|
||||
|
||||
it('should support unlisted visibility', async () => {
|
||||
setupYouTubePlatform()
|
||||
setupSocialAccount()
|
||||
setupMedia()
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||
|
||||
mockVideosInsert.mockResolvedValueOnce({
|
||||
data: { id: 'yt-unlisted-1' },
|
||||
})
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 1,
|
||||
metadata: { ...defaultMetadata, visibility: 'unlisted' },
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockVideosInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestBody: expect.objectContaining({
|
||||
status: { privacyStatus: 'unlisted' },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should omit categoryId from snippet when not provided', async () => {
|
||||
setupYouTubePlatform()
|
||||
setupSocialAccount()
|
||||
setupMedia()
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockCreateReadStream.mockReturnValue('fake-stream')
|
||||
|
||||
mockVideosInsert.mockResolvedValueOnce({
|
||||
data: { id: 'yt-nocat-1' },
|
||||
})
|
||||
|
||||
const { VideoUploadService } = await import(
|
||||
'@/lib/integrations/youtube/VideoUploadService'
|
||||
)
|
||||
|
||||
const service = new VideoUploadService(mockPayload)
|
||||
const { categoryId: _, ...metadataWithoutCategory } = defaultMetadata
|
||||
const result = await service.uploadVideo({
|
||||
mediaId: 1,
|
||||
metadata: metadataWithoutCategory,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockVideosInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestBody: expect.objectContaining({
|
||||
snippet: expect.objectContaining({
|
||||
title: 'My Test Video',
|
||||
categoryId: undefined,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue