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:
Martin Porwoll 2026-02-14 13:31:25 +00:00
parent fb4d5a8fe5
commit 6cc3011804
2 changed files with 612 additions and 0 deletions

View 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 }

View 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,
}),
}),
}),
)
})
})