UNPKG

@memberjunction/actions-bizapps-social

Version:

Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer

374 lines (332 loc) 12.7 kB
import { RegisterClass } from '@memberjunction/global'; import { BaseSocialMediaAction } from '../../base/base-social.action'; import { UserInfo } from '@memberjunction/core'; import axios, { AxiosInstance, AxiosError } from 'axios'; import { SocialPost, MediaFile } from '../../base/base-social.action'; import { BaseAction } from '@memberjunction/actions'; /** * Base class for all YouTube actions. * Handles YouTube Data API v3 authentication and common functionality. */ @RegisterClass(BaseAction, 'YouTubeBaseAction') export abstract class YouTubeBaseAction extends BaseSocialMediaAction { protected override get platformName(): string { return 'YouTube'; } protected override get apiBaseUrl(): string { return 'https://www.googleapis.com/youtube/v3'; } /** * Axios instance for API requests */ protected axiosInstance: AxiosInstance; /** * Initialize the axios instance with base configuration */ protected initializeAxios(): void { this.axiosInstance = axios.create({ baseURL: this.apiBaseUrl, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }); } /** * YouTube-specific OAuth token refresh */ protected async refreshAccessToken(): Promise<void> { const refreshToken = this.getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token available for YouTube'); } try { const response = await axios.post('https://oauth2.googleapis.com/token', { client_id: this.getCustomAttribute(2), // Store client ID in CustomAttribute2 client_secret: this.getCustomAttribute(3), // Store client secret in CustomAttribute3 refresh_token: refreshToken, grant_type: 'refresh_token' }); const { access_token, expires_in } = response.data; await this.updateStoredTokens(access_token, undefined, expires_in); } catch (error) { throw new Error(`Failed to refresh YouTube access token: ${error.message}`); } } /** * Make authenticated request to YouTube API */ protected async makeYouTubeRequest<T = any>( endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any, params?: Record<string, any>, contextUser?: UserInfo ): Promise<T> { if (!this.axiosInstance) { this.initializeAxios(); } return this.makeAuthenticatedRequest(async (token) => { try { const response = await this.axiosInstance.request<T>({ url: endpoint, method, data, params, headers: { 'Authorization': `Bearer ${token}` } }); return response.data; } catch (error) { if (axios.isAxiosError(error)) { this.handleYouTubeApiError(error); } throw error; } }); } /** * Handle YouTube API errors */ protected handleYouTubeApiError(error: AxiosError): void { const response = error.response; if (!response) { throw new Error('Network error occurred'); } const errorData: any = response.data; const errorMessage = errorData?.error?.message || response.statusText; const errorCode = errorData?.error?.code || response.status; // Check for quota exceeded if (errorCode === 403 && errorMessage.includes('quota')) { throw new Error(`YouTube API quota exceeded. ${errorMessage}`); } // Check for rate limiting if (errorCode === 429) { const retryAfter = response.headers['retry-after']; throw new Error(`Rate limit exceeded. Retry after ${retryAfter || '60'} seconds`); } throw new Error(`YouTube API error (${errorCode}): ${errorMessage}`); } /** * Upload video to YouTube */ protected async uploadVideo( videoFile: MediaFile, metadata: { title: string; description?: string; tags?: string[]; categoryId?: string; privacyStatus?: 'private' | 'unlisted' | 'public'; } ): Promise<string> { // YouTube requires a resumable upload for videos const uploadUrl = await this.initiateResumableUpload(metadata); const videoId = await this.performResumableUpload(uploadUrl, videoFile); return videoId; } /** * Initiate resumable upload session */ private async initiateResumableUpload(metadata: any): Promise<string> { const response = await this.makeYouTubeRequest<any>( '/videos', 'POST', { snippet: { title: metadata.title, description: metadata.description || '', tags: metadata.tags || [], categoryId: metadata.categoryId || '22' // Default to People & Blogs }, status: { privacyStatus: metadata.privacyStatus || 'private' } }, { uploadType: 'resumable', part: 'snippet,status' } ); return response.headers.location; } /** * Perform the actual video upload */ private async performResumableUpload(uploadUrl: string, videoFile: MediaFile): Promise<string> { const videoData = Buffer.isBuffer(videoFile.data) ? videoFile.data : Buffer.from(videoFile.data, 'base64'); const response = await axios.put(uploadUrl, videoData, { headers: { 'Content-Type': videoFile.mimeType, 'Content-Length': videoData.length.toString(), 'Authorization': `Bearer ${this.getAccessToken()}` } }); return response.data.id; } /** * Upload single media file (thumbnail) */ protected async uploadSingleMedia(file: MediaFile): Promise<string> { // For YouTube, this would typically be used for thumbnails // The actual implementation would upload to YouTube's thumbnail endpoint throw new Error('Direct media upload not supported. Use uploadVideo for videos or setThumbnail for thumbnails.'); } /** * Convert YouTube video to standard social post format */ protected normalizePost(youtubeVideo: any): SocialPost { return { id: youtubeVideo.id, platform: 'YouTube', profileId: youtubeVideo.snippet.channelId, content: youtubeVideo.snippet.description || '', mediaUrls: [`https://www.youtube.com/watch?v=${youtubeVideo.id}`], publishedAt: new Date(youtubeVideo.snippet.publishedAt), scheduledFor: youtubeVideo.status.publishAt ? new Date(youtubeVideo.status.publishAt) : undefined, analytics: this.extractVideoAnalytics(youtubeVideo), platformSpecificData: { title: youtubeVideo.snippet.title, tags: youtubeVideo.snippet.tags || [], categoryId: youtubeVideo.snippet.categoryId, duration: youtubeVideo.contentDetails?.duration, definition: youtubeVideo.contentDetails?.definition, privacyStatus: youtubeVideo.status.privacyStatus, embeddable: youtubeVideo.status.embeddable, thumbnails: youtubeVideo.snippet.thumbnails } }; } /** * Extract analytics from video statistics */ private extractVideoAnalytics(video: any): any { if (!video.statistics) { return undefined; } return this.normalizeAnalytics({ impressions: parseInt(video.statistics.viewCount || '0'), engagements: parseInt(video.statistics.likeCount || '0') + parseInt(video.statistics.commentCount || '0'), clicks: 0, // YouTube doesn't provide click data shares: 0, // YouTube doesn't provide share count comments: parseInt(video.statistics.commentCount || '0'), likes: parseInt(video.statistics.likeCount || '0'), reach: parseInt(video.statistics.viewCount || '0'), saves: parseInt(video.statistics.favoriteCount || '0'), videoViews: parseInt(video.statistics.viewCount || '0'), dislikes: parseInt(video.statistics.dislikeCount || '0') }); } /** * Search for videos */ protected async searchPosts(params: any): Promise<SocialPost[]> { const searchParams: any = { part: 'snippet', type: 'video', maxResults: params.limit || 50, order: params.sortBy || 'relevance' }; // Add search query if (params.query) { searchParams.q = params.query; } // Add channel filter if searching within a specific channel if (params.channelId || this.getCustomAttribute(1)) { searchParams.channelId = params.channelId || this.getCustomAttribute(1); } // Add date filters if (params.startDate) { searchParams.publishedAfter = this.formatDate(params.startDate); } if (params.endDate) { searchParams.publishedBefore = this.formatDate(params.endDate); } // Add pagination if (params.pageToken) { searchParams.pageToken = params.pageToken; } const response = await this.makeYouTubeRequest<any>( '/search', 'GET', undefined, searchParams ); // Get full video details for search results const videoIds = response.items.map((item: any) => item.id.videoId).join(','); const videosResponse = await this.makeYouTubeRequest<any>( '/videos', 'GET', undefined, { part: 'snippet,statistics,status,contentDetails', id: videoIds } ); return videosResponse.items.map((video: any) => this.normalizePost(video)); } /** * Get quota cost for an operation */ protected getQuotaCost(operation: string): number { const quotaCosts: Record<string, number> = { 'videos.list': 1, 'videos.insert': 1600, 'videos.update': 50, 'videos.delete': 50, 'search.list': 100, 'channels.list': 1, 'playlists.list': 1, 'playlists.insert': 50, 'playlistItems.insert': 50, 'comments.list': 1, 'commentThreads.list': 1 }; return quotaCosts[operation] || 1; } /** * Parse ISO 8601 duration to seconds */ protected parseDuration(isoDuration: string): number { const matches = isoDuration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (!matches) return 0; const hours = parseInt(matches[1] || '0'); const minutes = parseInt(matches[2] || '0'); const seconds = parseInt(matches[3] || '0'); return hours * 3600 + minutes * 60 + seconds; } /** * Format bytes to human readable size */ protected formatBytes(bytes: number): string { const sizes = ['Bytes', 'KB', 'MB', 'GB']; if (bytes === 0) return '0 Bytes'; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; } /** * Validate video file */ protected validateVideoFile(file: MediaFile): void { const allowedTypes = [ 'video/mp4', 'video/x-msvideo', // AVI 'video/quicktime', // MOV 'video/x-ms-wmv', // WMV 'video/x-flv', // FLV 'video/webm' ]; if (!allowedTypes.includes(file.mimeType)) { throw new Error(`Unsupported video format: ${file.mimeType}`); } // YouTube max file size is 128GB or 12 hours const maxSize = 128 * 1024 * 1024 * 1024; // 128GB if (file.size > maxSize) { throw new Error(`Video file too large. Maximum size is ${this.formatBytes(maxSize)}`); } } }