@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
text/typescript
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.
*/
(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)}`);
}
}
}