diff --git a/src/lib/integrations/youtube/VideoUploadService.ts b/src/lib/integrations/youtube/VideoUploadService.ts new file mode 100644 index 0000000..8b30ade --- /dev/null +++ b/src/lib/integrations/youtube/VideoUploadService.ts @@ -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 { + 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 } diff --git a/tests/unit/youtube/video-upload-service.unit.spec.ts b/tests/unit/youtube/video-upload-service.unit.spec.ts new file mode 100644 index 0000000..98dfe7d --- /dev/null +++ b/tests/unit/youtube/video-upload-service.unit.spec.ts @@ -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): void { + mockFind.mockResolvedValueOnce({ + docs: [ + { + id: 10, + isActive: true, + credentials: { + accessToken: 'yt-access-token', + refreshToken: 'yt-refresh-token', + }, + ...overrides, + }, + ], + totalDocs: 1, + }) +} + +function setupMedia(overrides?: Record): 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, + }), + }), + }), + ) + }) +})