my-youtube-api 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,182 +38,136 @@ export interface YouTubeUploadResult {
38
38
  }
39
39
 
40
40
  export class YoutubeVideoPoster {
41
+ private accessToken: string;
41
42
  private youtube: youtube_v3.Youtube;
42
43
 
43
- constructor(private access_token: string) {
44
- if (!access_token) {
44
+ constructor(accessToken: string) {
45
+ if (!accessToken) {
45
46
  throw new YouTubeAuthenticationError('Access token is required');
46
47
  }
47
- this.youtube = google.youtube({ version: "v3", auth: this.access_token });
48
+ this.accessToken = accessToken;
49
+
50
+ // ✅ CORRECT: Create OAuth2 client and set credentials
51
+ const oauth2Client = new google.auth.OAuth2();
52
+ oauth2Client.setCredentials({
53
+ access_token: accessToken
54
+ });
55
+
56
+ this.youtube = google.youtube({
57
+ version: "v3",
58
+ auth: oauth2Client
59
+ });
48
60
  }
49
61
 
50
62
  /**
51
- * Handle YouTube API errors and throw appropriate custom errors
63
+ * Upload a video from a Cloudinary URL to YouTube using direct API calls
52
64
  */
53
- private handleYouTubeError(error: any, context: string): never {
54
- // Handle Google API errors
55
- if (error.errors && Array.isArray(error.errors)) {
56
- const apiError = error.errors[0];
57
- const message = `${context}: ${apiError.message}`;
58
- const reason = apiError.reason;
59
- const domain = apiError.domain;
60
-
61
- switch (reason) {
62
- case 'authError':
63
- case 'invalidCredentials':
64
- case 'required':
65
- throw new YouTubeAuthenticationError(message, apiError);
66
-
67
- case 'quotaExceeded':
68
- case 'dailyLimitExceeded':
69
- case 'userRateLimitExceeded':
70
- throw new YouTubeQuotaError(message, apiError);
71
-
72
- case 'rateLimitExceeded':
73
- case 'userRateLimitExceeded':
74
- throw new YouTubeRateLimitError(message, apiError);
75
-
76
- case 'invalidValue':
77
- case 'invalid':
78
- throw new YouTubeValidationError(message, apiError);
79
-
80
- case 'processingFailed':
81
- case 'failed':
82
- throw new YouTubeProcessingError(message, apiError);
83
-
84
- default:
85
- throw new YouTubeApiError(message, `YOUTUBE_${reason?.toUpperCase()}`, error.code, apiError);
86
- }
87
- }
88
-
89
- // Handle axios/network errors
90
- if (error.response?.data?.error) {
91
- const youtubeError = error.response.data.error;
92
- const message = `${context}: ${youtubeError.message}`;
93
- const code = youtubeError.code;
94
-
95
- switch (code) {
96
- case 401:
97
- case 403:
98
- throw new YouTubeAuthenticationError(message, youtubeError);
99
- case 429:
100
- throw new YouTubeRateLimitError(message, youtubeError);
101
- case 400:
102
- throw new YouTubeValidationError(message, youtubeError);
103
- case 500:
104
- case 503:
105
- throw new YouTubeProcessingError(message, youtubeError);
106
- default:
107
- throw new YouTubeApiError(message, `YOUTUBE_${code}`, code, youtubeError);
108
- }
109
- }
110
-
111
- // Handle timeout errors
112
- if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
113
- throw new YouTubeTimeoutError(`${context}: Request timed out`, error);
114
- }
65
+ public async uploadFromCloudUrl(videoUrl: string, metadata: VideoMetadata): Promise<YouTubeUploadResult> {
66
+ let videoStream: Readable | null = null;
115
67
 
116
- // Handle network errors
117
- if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
118
- throw new YouTubeApiError(`${context}: Network error - ${error.message}`, 'NETWORK_ERROR', 503);
119
- }
68
+ try {
69
+ console.log(`📹 Fetching video from: ${videoUrl}`);
120
70
 
121
- // Handle media/upload specific errors
122
- if (error.message?.includes('stream') || error.message?.includes('video') || error.message?.includes('upload')) {
123
- throw new YouTubeUploadError(`${context}: ${error.message}`, error);
124
- }
71
+ // Fetch video stream
72
+ const response = await axios.get(videoUrl, {
73
+ responseType: "stream",
74
+ timeout: 30000,
75
+ maxContentLength: 1024 * 1024 * 1024, // 1GB max
76
+ });
125
77
 
126
- // Default to generic API error
127
- if (error instanceof YouTubeApiError) {
128
- throw error;
129
- }
78
+ videoStream = response.data;
130
79
 
131
- throw new YouTubeApiError(`${context}: ${error.message}`, 'UNKNOWN_ERROR', error.response?.status);
132
- }
80
+ if (!videoStream || !(videoStream instanceof Readable)) {
81
+ throw new YouTubeMediaError('Invalid video stream received from URL');
82
+ }
133
83
 
134
- /**
135
- * Validate video metadata
136
- */
137
- private validateMetadata(metadata: VideoMetadata): void {
138
- if (!metadata.title || metadata.title.trim().length === 0) {
139
- throw new YouTubeValidationError('Video title is required');
140
- }
84
+ // Prepare metadata
85
+ const requestBody = {
86
+ snippet: {
87
+ title: metadata.title.substring(0, 100),
88
+ description: (metadata.description || "").substring(0, 5000),
89
+ tags: metadata.tags || [],
90
+ categoryId: metadata.categoryId || "22"
91
+ },
92
+ status: {
93
+ privacyStatus: metadata.privacyStatus || "private",
94
+ embeddable: metadata.embeddable !== false,
95
+ publicStatsViewable: metadata.publicStatsViewable !== false,
96
+ license: metadata.license || "youtube",
97
+ selfDeclaredMadeForKids: metadata.madeForKids || false
98
+ }
99
+ };
141
100
 
142
- if (metadata.title.length > 100) {
143
- throw new YouTubeValidationError('Video title must be 100 characters or less');
144
- }
101
+ console.log(`🚀 Uploading video to YouTube: "${metadata.title}"`);
145
102
 
146
- if (metadata.description && metadata.description.length > 5000) {
147
- throw new YouTubeValidationError('Video description must be 5000 characters or less');
148
- }
103
+ // Upload using Google APIs client with OAuth2
104
+ const uploadResponse = await this.youtube.videos.insert({
105
+ part: ["snippet", "status"],
106
+ requestBody: requestBody,
107
+ media: {
108
+ body: videoStream,
109
+ mimeType: 'video/*'
110
+ },
111
+ notifySubscribers: metadata.notifySubscribers !== false
112
+ });
149
113
 
150
- if (metadata.tags && metadata.tags.length > 500) {
151
- throw new YouTubeValidationError('Maximum 500 tags allowed');
152
- }
114
+ const videoId = uploadResponse.data.id;
153
115
 
154
- if (metadata.tags) {
155
- for (const tag of metadata.tags) {
156
- if (tag.length > 30) {
157
- throw new YouTubeValidationError(`Tag "${tag}" must be 30 characters or less`);
158
- }
116
+ if (!videoId) {
117
+ throw new YouTubeUploadError('Video ID not returned from YouTube API');
159
118
  }
160
- }
161
119
 
162
- const validPrivacyStatuses = ['public', 'private', 'unlisted'];
163
- if (metadata.privacyStatus && !validPrivacyStatuses.includes(metadata.privacyStatus)) {
164
- throw new YouTubeValidationError(`Privacy status must be one of: ${validPrivacyStatuses.join(', ')}`);
165
- }
166
-
167
- const validLicenses = ['youtube', 'creativeCommon'];
168
- if (metadata.license && !validLicenses.includes(metadata.license)) {
169
- throw new YouTubeValidationError(`License must be one of: ${validLicenses.join(', ')}`);
170
- }
171
- }
120
+ console.log(`✅ Video uploaded successfully: ${videoId}`);
172
121
 
173
- /**
174
- * Fetch video stream from URL with proper error handling
175
- */
176
- private async fetchVideoStream(videoUrl: string): Promise<Readable> {
177
- try {
178
- console.log(`📹 Fetching video from: ${videoUrl}`);
122
+ // Wait for processing to complete
123
+ await this.waitForVideoProcessing(videoId);
179
124
 
180
- const response = await axios.get(videoUrl, {
181
- responseType: "stream",
182
- timeout: 30000, // 30 second timeout
183
- maxContentLength: 1024 * 1024 * 1024, // 1GB max file size
184
- validateStatus: (status) => status === 200
185
- });
125
+ const result: YouTubeUploadResult = {
126
+ id: videoId,
127
+ status: 'processed',
128
+ url: `https://www.youtube.com/watch?v=${videoId}`,
129
+ title: metadata.title,
130
+ description: metadata.description,
131
+ };
186
132
 
187
- if (!response.data || !(response.data instanceof Readable)) {
188
- throw new YouTubeMediaError('Invalid video stream received from URL');
189
- }
133
+ console.log(`🎉 YouTube video published successfully: ${result.url}`);
190
134
 
191
- return response.data;
135
+ return result;
192
136
 
193
137
  } catch (error: any) {
194
- if (error instanceof YouTubeApiError) {
195
- throw error;
138
+ // Clean up stream
139
+ if (videoStream) {
140
+ videoStream.destroy();
196
141
  }
197
142
 
198
- if (error.response?.status === 404) {
199
- throw new YouTubeMediaError(`Video not found at URL: ${videoUrl}`);
200
- } else if (error.response?.status === 403) {
201
- throw new YouTubeMediaError(`Access denied to video URL: ${videoUrl}`);
202
- } else if (error.code === 'ECONNABORTED') {
203
- throw new YouTubeTimeoutError(`Timeout while fetching video from URL: ${videoUrl}`);
143
+ console.error("YouTube upload failed:", error);
144
+
145
+ // Handle specific Google API errors
146
+ if (error.code === 401) {
147
+ throw new YouTubeAuthenticationError(
148
+ 'Authentication failed. Please reconnect your YouTube account. ' +
149
+ 'The access token may have expired or is invalid.'
150
+ );
151
+ } else if (error.code === 403) {
152
+ if (error.message?.includes('quota')) {
153
+ throw new YouTubeQuotaError('YouTube API quota exceeded. Please try again tomorrow.');
154
+ } else {
155
+ throw new YouTubeAuthenticationError('Access denied. Please check your YouTube permissions.');
156
+ }
157
+ } else if (error.code === 400) {
158
+ throw new YouTubeValidationError(`Invalid request: ${error.message}`);
159
+ } else if (error.code === 503) {
160
+ throw new YouTubeProcessingError('YouTube service temporarily unavailable. Please try again.');
204
161
  } else {
205
- throw new YouTubeMediaError(`Failed to fetch video from URL: ${error.message}`);
162
+ throw new YouTubeApiError(`YouTube API error: ${error.message}`);
206
163
  }
207
164
  }
208
165
  }
209
166
 
210
167
  /**
211
- * Wait for video processing to complete
168
+ * Wait for video processing to complete using direct API calls
212
169
  */
213
- private async waitForVideoProcessing(
214
- videoId: string,
215
- timeoutMs: number = 300000 // 5 minutes
216
- ): Promise<void> {
170
+ private async waitForVideoProcessing(videoId: string, timeoutMs: number = 300000): Promise<void> {
217
171
  const startTime = Date.now();
218
172
  const maxAttempts = 30;
219
173
  let attempts = 0;
@@ -248,8 +202,7 @@ export class YoutubeVideoPoster {
248
202
  throw new YouTubeProcessingError(`Video was rejected: ${rejectionReason}`);
249
203
  case 'uploaded':
250
204
  case 'processing':
251
- // Wait and check again
252
- const waitTime = Math.min(1000 * Math.pow(2, attempts), 10000); // Exponential backoff, max 10s
205
+ const waitTime = Math.min(1000 * Math.pow(2, attempts), 10000);
253
206
  console.log(`⏳ Video status: ${status}, waiting ${waitTime}ms...`);
254
207
  await new Promise(resolve => setTimeout(resolve, waitTime));
255
208
  break;
@@ -261,7 +214,9 @@ export class YoutubeVideoPoster {
261
214
  if (error instanceof YouTubeApiError) {
262
215
  throw error;
263
216
  }
264
- this.handleYouTubeError(error, "Failed to check video processing status");
217
+ // Continue waiting on network errors
218
+ console.warn(`Error checking video status (attempt ${attempts}):`, error.message);
219
+ await new Promise(resolve => setTimeout(resolve, 5000));
265
220
  }
266
221
  }
267
222
 
@@ -269,69 +224,55 @@ export class YoutubeVideoPoster {
269
224
  }
270
225
 
271
226
  /**
272
- * Add video to playlist if specified
227
+ * Alternative method using direct axios calls (if Google client still has issues)
273
228
  */
274
- private async addToPlaylist(videoId: string, playlistId: string): Promise<void> {
229
+ public async uploadFromCloudUrlDirect(videoUrl: string, metadata: VideoMetadata): Promise<YouTubeUploadResult> {
275
230
  try {
276
- await this.youtube.playlistItems.insert({
277
- part: ['snippet'],
278
- requestBody: {
279
- snippet: {
280
- playlistId: playlistId,
281
- resourceId: {
282
- kind: 'youtube#video',
283
- videoId: videoId
284
- }
285
- }
286
- }
287
- });
288
- console.log(`✅ Video ${videoId} added to playlist ${playlistId}`);
289
- } catch (error: any) {
290
- console.warn(`⚠️ Failed to add video to playlist: ${error.message}`);
291
- // Don't throw error for playlist addition failure as video upload was successful
292
- }
293
- }
231
+ console.log(`📹 Using direct upload method for: ${videoUrl}`);
294
232
 
295
- /**
296
- * Upload a video from a Cloudinary URL to YouTube
297
- */
298
- public async uploadFromCloudUrl2(videoUrl: string, metadata: VideoMetadata): Promise<YouTubeUploadResult> {
299
- let videoStream: Readable | null = null;
300
-
301
- try {
302
- // Validate metadata first
303
- this.validateMetadata(metadata);
233
+ // First, get the video file as buffer
234
+ const videoResponse = await axios.get(videoUrl, {
235
+ responseType: 'arraybuffer',
236
+ timeout: 30000,
237
+ });
304
238
 
305
- // Fetch video stream
306
- videoStream = await this.fetchVideoStream(videoUrl);
239
+ const videoBuffer = Buffer.from(videoResponse.data);
307
240
 
308
- // Prepare metadata payload
309
- const metadataPayload: youtube_v3.Params$Resource$Videos$Insert = {
310
- part: ["snippet", "status"],
311
- notifySubscribers: metadata.notifySubscribers !== false,
312
- requestBody: {
313
- snippet: {
314
- title: metadata.title.substring(0, 100),
315
- description: (metadata.description || "").substring(0, 5000),
316
- tags: (metadata.tags || []).slice(0, 500),
317
- categoryId: metadata.categoryId || "22"
318
- },
319
- status: {
320
- privacyStatus: metadata.privacyStatus || "private",
321
- embeddable: metadata.embeddable !== false,
322
- publicStatsViewable: metadata.publicStatsViewable !== false,
323
- license: metadata.license || "youtube",
324
- selfDeclaredMadeForKids: metadata.madeForKids || false
325
- }
241
+ // Prepare the metadata
242
+ const metadataPayload = {
243
+ snippet: {
244
+ title: metadata.title.substring(0, 100),
245
+ description: (metadata.description || "").substring(0, 5000),
246
+ tags: metadata.tags || [],
247
+ categoryId: metadata.categoryId || "22"
326
248
  },
327
- media: { body: videoStream }
249
+ status: {
250
+ privacyStatus: metadata.privacyStatus || "private",
251
+ embeddable: metadata.embeddable !== false,
252
+ publicStatsViewable: metadata.publicStatsViewable !== false,
253
+ license: metadata.license || "youtube",
254
+ selfDeclaredMadeForKids: metadata.madeForKids || false
255
+ }
328
256
  };
329
257
 
330
- console.log(`🚀 Uploading video to YouTube: "${metadata.title}"`);
331
-
332
- // Upload video
333
- const uploadResponse = await this.youtube.videos.insert(metadataPayload)
334
- .catch(error => this.handleYouTubeError(error, "Failed to upload video to YouTube"));
258
+ // Upload using direct multipart request
259
+ const formData = new FormData();
260
+ formData.append('metadata', JSON.stringify(metadataPayload));
261
+ formData.append('video', new Blob([videoBuffer]), 'video.mp4');
262
+
263
+ const uploadResponse = await axios.post(
264
+ 'https://www.googleapis.com/upload/youtube/v3/videos?part=snippet,status',
265
+ formData,
266
+ {
267
+ headers: {
268
+ 'Authorization': `Bearer ${this.accessToken}`,
269
+ 'Content-Type': 'multipart/related',
270
+ },
271
+ maxContentLength: Infinity,
272
+ maxBodyLength: Infinity,
273
+ timeout: 60000,
274
+ }
275
+ );
335
276
 
336
277
  const videoId = uploadResponse.data.id;
337
278
 
@@ -339,118 +280,81 @@ export class YoutubeVideoPoster {
339
280
  throw new YouTubeUploadError('Video ID not returned from YouTube API');
340
281
  }
341
282
 
342
- console.log(`✅ Video uploaded successfully: ${videoId}`);
283
+ console.log(`✅ Video uploaded successfully via direct method: ${videoId}`);
343
284
 
344
- // Wait for processing to complete
345
- await this.waitForVideoProcessing(videoId);
346
-
347
- // Add to playlist if specified
348
- if (metadata.playlistId) {
349
- await this.addToPlaylist(videoId, metadata.playlistId);
350
- }
285
+ // Wait for processing
286
+ await this.waitForVideoProcessingDirect(videoId);
351
287
 
352
- const result: YouTubeUploadResult = {
288
+ return {
353
289
  id: videoId,
354
290
  status: 'processed',
355
291
  url: `https://www.youtube.com/watch?v=${videoId}`,
356
292
  title: metadata.title,
357
293
  description: metadata.description,
358
- // You can add thumbnail URL here if needed
359
294
  };
360
295
 
361
- console.log(`🎉 YouTube video published successfully: ${result.url}`);
362
-
363
- return result;
364
-
365
296
  } catch (error: any) {
366
- // Clean up stream if it exists
367
- if (videoStream) {
368
- videoStream.destroy();
369
- }
370
-
371
- if (error instanceof YouTubeApiError) {
372
- throw error;
297
+ console.error("Direct YouTube upload failed:", error.response?.data || error.message);
298
+
299
+ if (error.response?.status === 401) {
300
+ throw new YouTubeAuthenticationError(
301
+ 'Authentication failed. Please reconnect your YouTube account.'
302
+ );
303
+ } else if (error.response?.status === 403) {
304
+ throw new YouTubeQuotaError('YouTube API quota exceeded.');
305
+ } else {
306
+ throw new YouTubeApiError(`Upload failed: ${error.message}`);
373
307
  }
374
-
375
- this.handleYouTubeError(error, "Unexpected error during YouTube video upload");
376
308
  }
377
309
  }
378
310
 
379
311
  /**
380
- * Upload a video from a Cloudinary URL to YouTube
381
- * @param videoUrl URL of the video on Cloudinary
382
- * @param metadata Video metadata
312
+ * Wait for processing using direct API calls
383
313
  */
384
- public async uploadFromCloudUrl(videoUrl: string, metadata: VideoMetadata): Promise<youtube_v3.Schema$Video> {
385
- try {
386
- // Fetch video as stream from Cloudinary
387
- const response = await axios.get(videoUrl, { responseType: "stream" });
388
- const videoStream = response.data as Readable;
314
+ private async waitForVideoProcessingDirect(videoId: string, timeoutMs: number = 300000): Promise<void> {
315
+ const startTime = Date.now();
316
+ let attempts = 0;
389
317
 
390
- // Prepare metadata payload
391
- const metadataPayload: youtube_v3.Params$Resource$Videos$Insert = {
392
- part: ["snippet", "status"],
393
- requestBody: {
394
- snippet: {
395
- title: metadata.title.substring(0, 100),
396
- description: (metadata.description || "").substring(0, 5000),
397
- tags: (metadata.tags || []).slice(0, 5),
398
- categoryId: metadata.categoryId || "22"
399
- },
400
- status: {
401
- privacyStatus: metadata.privacyStatus || "private",
402
- embeddable: metadata.embeddable !== false,
403
- publicStatsViewable: metadata.publicStatsViewable !== false,
404
- license: metadata.license || "youtube",
405
- selfDeclaredMadeForKids: metadata.madeForKids || false
406
- }
407
- },
408
- media: { body: videoStream }
409
- };
318
+ while (Date.now() - startTime < timeoutMs) {
319
+ attempts++;
410
320
 
411
- // Upload video
412
- const res = await this.youtube.videos.insert(metadataPayload);
413
- return res.data;
321
+ try {
322
+ const response = await axios.get(
323
+ `https://www.googleapis.com/youtube/v3/videos?part=status&id=${videoId}`,
324
+ {
325
+ headers: {
326
+ 'Authorization': `Bearer ${this.accessToken}`
327
+ }
328
+ }
329
+ );
414
330
 
415
- } catch (error: any) {
416
- console.error("YouTube upload failed:", error.response?.data || error.message);
417
- throw new Error(`YouTube upload failed: ${error.response?.data?.error?.message || error.message}`);
418
- }
419
- }
331
+ const video = response.data.items?.[0];
332
+ if (!video) {
333
+ throw new YouTubeProcessingError(`Video ${videoId} not found`);
334
+ }
420
335
 
421
- /**
422
- * Get video details
423
- */
424
- public async getVideoDetails(videoId: string): Promise<youtube_v3.Schema$Video> {
425
- try {
426
- const response = await this.youtube.videos.list({
427
- part: ['snippet', 'status', 'contentDetails', 'statistics'],
428
- id: [videoId]
429
- });
336
+ const status = video.status?.uploadStatus;
430
337
 
431
- const video = response.data.items?.[0];
432
- if (!video) {
433
- throw new YouTubeValidationError(`Video not found: ${videoId}`);
434
- }
338
+ if (status === 'processed') {
339
+ console.log(`✅ Video processing completed: ${videoId}`);
340
+ return;
341
+ } else if (status === 'failed') {
342
+ throw new YouTubeProcessingError(`Video processing failed: ${video.status?.failureReason}`);
343
+ } else if (status === 'rejected') {
344
+ throw new YouTubeProcessingError(`Video rejected: ${video.status?.rejectionReason}`);
345
+ }
435
346
 
436
- return video;
347
+ // Wait with exponential backoff
348
+ const waitTime = Math.min(1000 * Math.pow(2, attempts), 10000);
349
+ console.log(`⏳ Video status: ${status}, waiting ${waitTime}ms...`);
350
+ await new Promise(resolve => setTimeout(resolve, waitTime));
437
351
 
438
- } catch (error: any) {
439
- this.handleYouTubeError(error, "Failed to get video details");
352
+ } catch (error: any) {
353
+ console.warn(`Error checking video status (attempt ${attempts}):`, error.message);
354
+ await new Promise(resolve => setTimeout(resolve, 5000));
355
+ }
440
356
  }
441
- }
442
357
 
443
- /**
444
- * Delete a video
445
- */
446
- public async deleteVideo(videoId: string): Promise<void> {
447
- try {
448
- await this.youtube.videos.delete({
449
- id: videoId
450
- });
451
- console.log(`✅ Video deleted successfully: ${videoId}`);
452
- } catch (error: any) {
453
- this.handleYouTubeError(error, "Failed to delete video");
454
- }
358
+ throw new YouTubeTimeoutError(`Video processing timed out after ${timeoutMs}ms`);
455
359
  }
456
360
  }