my-youtube-api 1.0.5 → 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.
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { youtube_v3 } from "googleapis";
|
|
2
1
|
export interface VideoMetadata {
|
|
3
2
|
title: string;
|
|
4
3
|
description?: string;
|
|
@@ -9,15 +8,35 @@ export interface VideoMetadata {
|
|
|
9
8
|
license?: "youtube" | "creativeCommon";
|
|
10
9
|
embeddable?: boolean;
|
|
11
10
|
publicStatsViewable?: boolean;
|
|
11
|
+
playlistId?: string;
|
|
12
|
+
notifySubscribers?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface YouTubeUploadResult {
|
|
15
|
+
id: string;
|
|
16
|
+
status: string;
|
|
17
|
+
url: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
thumbnailUrl?: string;
|
|
12
21
|
}
|
|
13
22
|
export declare class YoutubeVideoPoster {
|
|
14
|
-
private
|
|
23
|
+
private accessToken;
|
|
15
24
|
private youtube;
|
|
16
|
-
constructor(
|
|
25
|
+
constructor(accessToken: string);
|
|
26
|
+
/**
|
|
27
|
+
* Upload a video from a Cloudinary URL to YouTube using direct API calls
|
|
28
|
+
*/
|
|
29
|
+
uploadFromCloudUrl(videoUrl: string, metadata: VideoMetadata): Promise<YouTubeUploadResult>;
|
|
30
|
+
/**
|
|
31
|
+
* Wait for video processing to complete using direct API calls
|
|
32
|
+
*/
|
|
33
|
+
private waitForVideoProcessing;
|
|
34
|
+
/**
|
|
35
|
+
* Alternative method using direct axios calls (if Google client still has issues)
|
|
36
|
+
*/
|
|
37
|
+
uploadFromCloudUrlDirect(videoUrl: string, metadata: VideoMetadata): Promise<YouTubeUploadResult>;
|
|
17
38
|
/**
|
|
18
|
-
*
|
|
19
|
-
* @param videoUrl URL of the video on Cloudinary
|
|
20
|
-
* @param metadata Video metadata
|
|
39
|
+
* Wait for processing using direct API calls
|
|
21
40
|
*/
|
|
22
|
-
|
|
41
|
+
private waitForVideoProcessingDirect;
|
|
23
42
|
}
|
|
@@ -1,47 +1,275 @@
|
|
|
1
|
+
// src/youtube-handlers/youtube-video-poster.ts
|
|
1
2
|
import { google } from "googleapis";
|
|
2
3
|
import axios from "axios";
|
|
4
|
+
import { Readable } from "stream";
|
|
5
|
+
import { YouTubeApiError, YouTubeAuthenticationError, YouTubeQuotaError, YouTubeMediaError, YouTubeUploadError, YouTubeValidationError, YouTubeProcessingError, YouTubeTimeoutError } from "../errors/youtube-api-errors";
|
|
3
6
|
export class YoutubeVideoPoster {
|
|
4
|
-
constructor(
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
constructor(accessToken) {
|
|
8
|
+
if (!accessToken) {
|
|
9
|
+
throw new YouTubeAuthenticationError('Access token is required');
|
|
10
|
+
}
|
|
11
|
+
this.accessToken = accessToken;
|
|
12
|
+
// ✅ CORRECT: Create OAuth2 client and set credentials
|
|
13
|
+
const oauth2Client = new google.auth.OAuth2();
|
|
14
|
+
oauth2Client.setCredentials({
|
|
15
|
+
access_token: accessToken
|
|
16
|
+
});
|
|
17
|
+
this.youtube = google.youtube({
|
|
18
|
+
version: "v3",
|
|
19
|
+
auth: oauth2Client
|
|
20
|
+
});
|
|
7
21
|
}
|
|
8
22
|
/**
|
|
9
|
-
* Upload a video from a Cloudinary URL to YouTube
|
|
10
|
-
* @param videoUrl URL of the video on Cloudinary
|
|
11
|
-
* @param metadata Video metadata
|
|
23
|
+
* Upload a video from a Cloudinary URL to YouTube using direct API calls
|
|
12
24
|
*/
|
|
13
25
|
async uploadFromCloudUrl(videoUrl, metadata) {
|
|
26
|
+
let videoStream = null;
|
|
14
27
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
console.log(`📹 Fetching video from: ${videoUrl}`);
|
|
29
|
+
// Fetch video stream
|
|
30
|
+
const response = await axios.get(videoUrl, {
|
|
31
|
+
responseType: "stream",
|
|
32
|
+
timeout: 30000,
|
|
33
|
+
maxContentLength: 1024 * 1024 * 1024, // 1GB max
|
|
34
|
+
});
|
|
35
|
+
videoStream = response.data;
|
|
36
|
+
if (!videoStream || !(videoStream instanceof Readable)) {
|
|
37
|
+
throw new YouTubeMediaError('Invalid video stream received from URL');
|
|
38
|
+
}
|
|
39
|
+
// Prepare metadata
|
|
40
|
+
const requestBody = {
|
|
41
|
+
snippet: {
|
|
42
|
+
title: metadata.title.substring(0, 100),
|
|
43
|
+
description: (metadata.description || "").substring(0, 5000),
|
|
44
|
+
tags: metadata.tags || [],
|
|
45
|
+
categoryId: metadata.categoryId || "22"
|
|
46
|
+
},
|
|
47
|
+
status: {
|
|
48
|
+
privacyStatus: metadata.privacyStatus || "private",
|
|
49
|
+
embeddable: metadata.embeddable !== false,
|
|
50
|
+
publicStatsViewable: metadata.publicStatsViewable !== false,
|
|
51
|
+
license: metadata.license || "youtube",
|
|
52
|
+
selfDeclaredMadeForKids: metadata.madeForKids || false
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
console.log(`🚀 Uploading video to YouTube: "${metadata.title}"`);
|
|
56
|
+
// Upload using Google APIs client with OAuth2
|
|
57
|
+
const uploadResponse = await this.youtube.videos.insert({
|
|
20
58
|
part: ["snippet", "status"],
|
|
21
|
-
requestBody:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
59
|
+
requestBody: requestBody,
|
|
60
|
+
media: {
|
|
61
|
+
body: videoStream,
|
|
62
|
+
mimeType: 'video/*'
|
|
63
|
+
},
|
|
64
|
+
notifySubscribers: metadata.notifySubscribers !== false
|
|
65
|
+
});
|
|
66
|
+
const videoId = uploadResponse.data.id;
|
|
67
|
+
if (!videoId) {
|
|
68
|
+
throw new YouTubeUploadError('Video ID not returned from YouTube API');
|
|
69
|
+
}
|
|
70
|
+
console.log(`✅ Video uploaded successfully: ${videoId}`);
|
|
71
|
+
// Wait for processing to complete
|
|
72
|
+
await this.waitForVideoProcessing(videoId);
|
|
73
|
+
const result = {
|
|
74
|
+
id: videoId,
|
|
75
|
+
status: 'processed',
|
|
76
|
+
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
77
|
+
title: metadata.title,
|
|
78
|
+
description: metadata.description,
|
|
79
|
+
};
|
|
80
|
+
console.log(`🎉 YouTube video published successfully: ${result.url}`);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
// Clean up stream
|
|
85
|
+
if (videoStream) {
|
|
86
|
+
videoStream.destroy();
|
|
87
|
+
}
|
|
88
|
+
console.error("YouTube upload failed:", error);
|
|
89
|
+
// Handle specific Google API errors
|
|
90
|
+
if (error.code === 401) {
|
|
91
|
+
throw new YouTubeAuthenticationError('Authentication failed. Please reconnect your YouTube account. ' +
|
|
92
|
+
'The access token may have expired or is invalid.');
|
|
93
|
+
}
|
|
94
|
+
else if (error.code === 403) {
|
|
95
|
+
if (error.message?.includes('quota')) {
|
|
96
|
+
throw new YouTubeQuotaError('YouTube API quota exceeded. Please try again tomorrow.');
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
throw new YouTubeAuthenticationError('Access denied. Please check your YouTube permissions.');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (error.code === 400) {
|
|
103
|
+
throw new YouTubeValidationError(`Invalid request: ${error.message}`);
|
|
104
|
+
}
|
|
105
|
+
else if (error.code === 503) {
|
|
106
|
+
throw new YouTubeProcessingError('YouTube service temporarily unavailable. Please try again.');
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
throw new YouTubeApiError(`YouTube API error: ${error.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Wait for video processing to complete using direct API calls
|
|
115
|
+
*/
|
|
116
|
+
async waitForVideoProcessing(videoId, timeoutMs = 300000) {
|
|
117
|
+
const startTime = Date.now();
|
|
118
|
+
const maxAttempts = 30;
|
|
119
|
+
let attempts = 0;
|
|
120
|
+
console.log(`⏳ Waiting for video processing: ${videoId}`);
|
|
121
|
+
while (attempts < maxAttempts && Date.now() - startTime < timeoutMs) {
|
|
122
|
+
attempts++;
|
|
123
|
+
try {
|
|
124
|
+
const statusResponse = await this.youtube.videos.list({
|
|
125
|
+
part: ['status'],
|
|
126
|
+
id: [videoId]
|
|
127
|
+
});
|
|
128
|
+
const video = statusResponse.data.items?.[0];
|
|
129
|
+
if (!video) {
|
|
130
|
+
throw new YouTubeProcessingError(`Video ${videoId} not found after upload`);
|
|
131
|
+
}
|
|
132
|
+
const status = video.status?.uploadStatus;
|
|
133
|
+
switch (status) {
|
|
134
|
+
case 'processed':
|
|
135
|
+
console.log(`✅ Video processing completed: ${videoId}`);
|
|
136
|
+
return;
|
|
137
|
+
case 'failed':
|
|
138
|
+
const failureReason = video.status?.failureReason || 'Unknown failure reason';
|
|
139
|
+
throw new YouTubeProcessingError(`Video processing failed: ${failureReason}`);
|
|
140
|
+
case 'rejected':
|
|
141
|
+
const rejectionReason = video.status?.rejectionReason || 'Unknown rejection reason';
|
|
142
|
+
throw new YouTubeProcessingError(`Video was rejected: ${rejectionReason}`);
|
|
143
|
+
case 'uploaded':
|
|
144
|
+
case 'processing':
|
|
145
|
+
const waitTime = Math.min(1000 * Math.pow(2, attempts), 10000);
|
|
146
|
+
console.log(`⏳ Video status: ${status}, waiting ${waitTime}ms...`);
|
|
147
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
148
|
+
break;
|
|
149
|
+
default:
|
|
150
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error instanceof YouTubeApiError) {
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
// Continue waiting on network errors
|
|
159
|
+
console.warn(`Error checking video status (attempt ${attempts}):`, error.message);
|
|
160
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
throw new YouTubeTimeoutError(`Video processing timed out after ${timeoutMs}ms`);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Alternative method using direct axios calls (if Google client still has issues)
|
|
167
|
+
*/
|
|
168
|
+
async uploadFromCloudUrlDirect(videoUrl, metadata) {
|
|
169
|
+
try {
|
|
170
|
+
console.log(`📹 Using direct upload method for: ${videoUrl}`);
|
|
171
|
+
// First, get the video file as buffer
|
|
172
|
+
const videoResponse = await axios.get(videoUrl, {
|
|
173
|
+
responseType: 'arraybuffer',
|
|
174
|
+
timeout: 30000,
|
|
175
|
+
});
|
|
176
|
+
const videoBuffer = Buffer.from(videoResponse.data);
|
|
177
|
+
// Prepare the metadata
|
|
178
|
+
const metadataPayload = {
|
|
179
|
+
snippet: {
|
|
180
|
+
title: metadata.title.substring(0, 100),
|
|
181
|
+
description: (metadata.description || "").substring(0, 5000),
|
|
182
|
+
tags: metadata.tags || [],
|
|
183
|
+
categoryId: metadata.categoryId || "22"
|
|
35
184
|
},
|
|
36
|
-
|
|
185
|
+
status: {
|
|
186
|
+
privacyStatus: metadata.privacyStatus || "private",
|
|
187
|
+
embeddable: metadata.embeddable !== false,
|
|
188
|
+
publicStatsViewable: metadata.publicStatsViewable !== false,
|
|
189
|
+
license: metadata.license || "youtube",
|
|
190
|
+
selfDeclaredMadeForKids: metadata.madeForKids || false
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
// Upload using direct multipart request
|
|
194
|
+
const formData = new FormData();
|
|
195
|
+
formData.append('metadata', JSON.stringify(metadataPayload));
|
|
196
|
+
formData.append('video', new Blob([videoBuffer]), 'video.mp4');
|
|
197
|
+
const uploadResponse = await axios.post('https://www.googleapis.com/upload/youtube/v3/videos?part=snippet,status', formData, {
|
|
198
|
+
headers: {
|
|
199
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
200
|
+
'Content-Type': 'multipart/related',
|
|
201
|
+
},
|
|
202
|
+
maxContentLength: Infinity,
|
|
203
|
+
maxBodyLength: Infinity,
|
|
204
|
+
timeout: 60000,
|
|
205
|
+
});
|
|
206
|
+
const videoId = uploadResponse.data.id;
|
|
207
|
+
if (!videoId) {
|
|
208
|
+
throw new YouTubeUploadError('Video ID not returned from YouTube API');
|
|
209
|
+
}
|
|
210
|
+
console.log(`✅ Video uploaded successfully via direct method: ${videoId}`);
|
|
211
|
+
// Wait for processing
|
|
212
|
+
await this.waitForVideoProcessingDirect(videoId);
|
|
213
|
+
return {
|
|
214
|
+
id: videoId,
|
|
215
|
+
status: 'processed',
|
|
216
|
+
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
217
|
+
title: metadata.title,
|
|
218
|
+
description: metadata.description,
|
|
37
219
|
};
|
|
38
|
-
// Upload video
|
|
39
|
-
const res = await this.youtube.videos.insert(metadataPayload);
|
|
40
|
-
return res.data;
|
|
41
220
|
}
|
|
42
221
|
catch (error) {
|
|
43
|
-
console.error("YouTube upload failed:", error.response?.data || error.message);
|
|
44
|
-
|
|
222
|
+
console.error("Direct YouTube upload failed:", error.response?.data || error.message);
|
|
223
|
+
if (error.response?.status === 401) {
|
|
224
|
+
throw new YouTubeAuthenticationError('Authentication failed. Please reconnect your YouTube account.');
|
|
225
|
+
}
|
|
226
|
+
else if (error.response?.status === 403) {
|
|
227
|
+
throw new YouTubeQuotaError('YouTube API quota exceeded.');
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
throw new YouTubeApiError(`Upload failed: ${error.message}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Wait for processing using direct API calls
|
|
236
|
+
*/
|
|
237
|
+
async waitForVideoProcessingDirect(videoId, timeoutMs = 300000) {
|
|
238
|
+
const startTime = Date.now();
|
|
239
|
+
let attempts = 0;
|
|
240
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
241
|
+
attempts++;
|
|
242
|
+
try {
|
|
243
|
+
const response = await axios.get(`https://www.googleapis.com/youtube/v3/videos?part=status&id=${videoId}`, {
|
|
244
|
+
headers: {
|
|
245
|
+
'Authorization': `Bearer ${this.accessToken}`
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
const video = response.data.items?.[0];
|
|
249
|
+
if (!video) {
|
|
250
|
+
throw new YouTubeProcessingError(`Video ${videoId} not found`);
|
|
251
|
+
}
|
|
252
|
+
const status = video.status?.uploadStatus;
|
|
253
|
+
if (status === 'processed') {
|
|
254
|
+
console.log(`✅ Video processing completed: ${videoId}`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
else if (status === 'failed') {
|
|
258
|
+
throw new YouTubeProcessingError(`Video processing failed: ${video.status?.failureReason}`);
|
|
259
|
+
}
|
|
260
|
+
else if (status === 'rejected') {
|
|
261
|
+
throw new YouTubeProcessingError(`Video rejected: ${video.status?.rejectionReason}`);
|
|
262
|
+
}
|
|
263
|
+
// Wait with exponential backoff
|
|
264
|
+
const waitTime = Math.min(1000 * Math.pow(2, attempts), 10000);
|
|
265
|
+
console.log(`⏳ Video status: ${status}, waiting ${waitTime}ms...`);
|
|
266
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
console.warn(`Error checking video status (attempt ${attempts}):`, error.message);
|
|
270
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
271
|
+
}
|
|
45
272
|
}
|
|
273
|
+
throw new YouTubeTimeoutError(`Video processing timed out after ${timeoutMs}ms`);
|
|
46
274
|
}
|
|
47
275
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
// src/youtube-handlers/youtube-video-poster.ts
|
|
1
2
|
import { google, youtube_v3 } from "googleapis";
|
|
2
3
|
import axios from "axios";
|
|
3
4
|
import { Readable } from "stream";
|
|
5
|
+
import {
|
|
6
|
+
YouTubeApiError,
|
|
7
|
+
YouTubeAuthenticationError,
|
|
8
|
+
YouTubeQuotaError,
|
|
9
|
+
YouTubeRateLimitError,
|
|
10
|
+
YouTubeMediaError,
|
|
11
|
+
YouTubeUploadError,
|
|
12
|
+
YouTubeValidationError,
|
|
13
|
+
YouTubeProcessingError,
|
|
14
|
+
YouTubeTimeoutError
|
|
15
|
+
} from "../errors/youtube-api-errors";
|
|
4
16
|
|
|
5
17
|
export interface VideoMetadata {
|
|
6
18
|
title: string;
|
|
@@ -9,57 +21,340 @@ export interface VideoMetadata {
|
|
|
9
21
|
categoryId?: string;
|
|
10
22
|
privacyStatus?: "public" | "private" | "unlisted";
|
|
11
23
|
madeForKids?: boolean;
|
|
12
|
-
license?: "youtube" | "creativeCommon";
|
|
24
|
+
license?: "youtube" | "creativeCommon";
|
|
13
25
|
embeddable?: boolean;
|
|
14
26
|
publicStatsViewable?: boolean;
|
|
27
|
+
playlistId?: string;
|
|
28
|
+
notifySubscribers?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface YouTubeUploadResult {
|
|
32
|
+
id: string;
|
|
33
|
+
status: string;
|
|
34
|
+
url: string;
|
|
35
|
+
title: string;
|
|
36
|
+
description?: string;
|
|
37
|
+
thumbnailUrl?: string;
|
|
15
38
|
}
|
|
16
39
|
|
|
17
40
|
export class YoutubeVideoPoster {
|
|
41
|
+
private accessToken: string;
|
|
18
42
|
private youtube: youtube_v3.Youtube;
|
|
19
43
|
|
|
20
|
-
constructor(
|
|
21
|
-
|
|
44
|
+
constructor(accessToken: string) {
|
|
45
|
+
if (!accessToken) {
|
|
46
|
+
throw new YouTubeAuthenticationError('Access token is required');
|
|
47
|
+
}
|
|
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
|
+
});
|
|
22
60
|
}
|
|
23
61
|
|
|
24
62
|
/**
|
|
25
|
-
* Upload a video from a Cloudinary URL to YouTube
|
|
26
|
-
* @param videoUrl URL of the video on Cloudinary
|
|
27
|
-
* @param metadata Video metadata
|
|
63
|
+
* Upload a video from a Cloudinary URL to YouTube using direct API calls
|
|
28
64
|
*/
|
|
29
|
-
public async uploadFromCloudUrl(videoUrl: string, metadata: VideoMetadata): Promise<
|
|
65
|
+
public async uploadFromCloudUrl(videoUrl: string, metadata: VideoMetadata): Promise<YouTubeUploadResult> {
|
|
66
|
+
let videoStream: Readable | null = null;
|
|
67
|
+
|
|
30
68
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
69
|
+
console.log(`📹 Fetching video from: ${videoUrl}`);
|
|
70
|
+
|
|
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
|
+
});
|
|
77
|
+
|
|
78
|
+
videoStream = response.data;
|
|
79
|
+
|
|
80
|
+
if (!videoStream || !(videoStream instanceof Readable)) {
|
|
81
|
+
throw new YouTubeMediaError('Invalid video stream received from URL');
|
|
82
|
+
}
|
|
34
83
|
|
|
35
|
-
// Prepare metadata
|
|
36
|
-
const
|
|
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
|
+
};
|
|
100
|
+
|
|
101
|
+
console.log(`🚀 Uploading video to YouTube: "${metadata.title}"`);
|
|
102
|
+
|
|
103
|
+
// Upload using Google APIs client with OAuth2
|
|
104
|
+
const uploadResponse = await this.youtube.videos.insert({
|
|
37
105
|
part: ["snippet", "status"],
|
|
38
|
-
requestBody:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
tags: (metadata.tags || []).slice(0, 5),
|
|
43
|
-
categoryId: metadata.categoryId || "22"
|
|
44
|
-
},
|
|
45
|
-
status: {
|
|
46
|
-
privacyStatus: metadata.privacyStatus || "private",
|
|
47
|
-
embeddable: metadata.embeddable !== false,
|
|
48
|
-
publicStatsViewable: metadata.publicStatsViewable !== false,
|
|
49
|
-
license: metadata.license || "youtube",
|
|
50
|
-
selfDeclaredMadeForKids: metadata.madeForKids || false
|
|
51
|
-
}
|
|
106
|
+
requestBody: requestBody,
|
|
107
|
+
media: {
|
|
108
|
+
body: videoStream,
|
|
109
|
+
mimeType: 'video/*'
|
|
52
110
|
},
|
|
53
|
-
|
|
111
|
+
notifySubscribers: metadata.notifySubscribers !== false
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const videoId = uploadResponse.data.id;
|
|
115
|
+
|
|
116
|
+
if (!videoId) {
|
|
117
|
+
throw new YouTubeUploadError('Video ID not returned from YouTube API');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(`✅ Video uploaded successfully: ${videoId}`);
|
|
121
|
+
|
|
122
|
+
// Wait for processing to complete
|
|
123
|
+
await this.waitForVideoProcessing(videoId);
|
|
124
|
+
|
|
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,
|
|
54
131
|
};
|
|
55
132
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return
|
|
133
|
+
console.log(`🎉 YouTube video published successfully: ${result.url}`);
|
|
134
|
+
|
|
135
|
+
return result;
|
|
59
136
|
|
|
60
137
|
} catch (error: any) {
|
|
61
|
-
|
|
62
|
-
|
|
138
|
+
// Clean up stream
|
|
139
|
+
if (videoStream) {
|
|
140
|
+
videoStream.destroy();
|
|
141
|
+
}
|
|
142
|
+
|
|
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.');
|
|
161
|
+
} else {
|
|
162
|
+
throw new YouTubeApiError(`YouTube API error: ${error.message}`);
|
|
163
|
+
}
|
|
63
164
|
}
|
|
64
165
|
}
|
|
65
|
-
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Wait for video processing to complete using direct API calls
|
|
169
|
+
*/
|
|
170
|
+
private async waitForVideoProcessing(videoId: string, timeoutMs: number = 300000): Promise<void> {
|
|
171
|
+
const startTime = Date.now();
|
|
172
|
+
const maxAttempts = 30;
|
|
173
|
+
let attempts = 0;
|
|
174
|
+
|
|
175
|
+
console.log(`⏳ Waiting for video processing: ${videoId}`);
|
|
176
|
+
|
|
177
|
+
while (attempts < maxAttempts && Date.now() - startTime < timeoutMs) {
|
|
178
|
+
attempts++;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const statusResponse = await this.youtube.videos.list({
|
|
182
|
+
part: ['status'],
|
|
183
|
+
id: [videoId]
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const video = statusResponse.data.items?.[0];
|
|
187
|
+
if (!video) {
|
|
188
|
+
throw new YouTubeProcessingError(`Video ${videoId} not found after upload`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const status = video.status?.uploadStatus;
|
|
192
|
+
|
|
193
|
+
switch (status) {
|
|
194
|
+
case 'processed':
|
|
195
|
+
console.log(`✅ Video processing completed: ${videoId}`);
|
|
196
|
+
return;
|
|
197
|
+
case 'failed':
|
|
198
|
+
const failureReason = video.status?.failureReason || 'Unknown failure reason';
|
|
199
|
+
throw new YouTubeProcessingError(`Video processing failed: ${failureReason}`);
|
|
200
|
+
case 'rejected':
|
|
201
|
+
const rejectionReason = video.status?.rejectionReason || 'Unknown rejection reason';
|
|
202
|
+
throw new YouTubeProcessingError(`Video was rejected: ${rejectionReason}`);
|
|
203
|
+
case 'uploaded':
|
|
204
|
+
case 'processing':
|
|
205
|
+
const waitTime = Math.min(1000 * Math.pow(2, attempts), 10000);
|
|
206
|
+
console.log(`⏳ Video status: ${status}, waiting ${waitTime}ms...`);
|
|
207
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
208
|
+
break;
|
|
209
|
+
default:
|
|
210
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
} catch (error: any) {
|
|
214
|
+
if (error instanceof YouTubeApiError) {
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
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));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
throw new YouTubeTimeoutError(`Video processing timed out after ${timeoutMs}ms`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Alternative method using direct axios calls (if Google client still has issues)
|
|
228
|
+
*/
|
|
229
|
+
public async uploadFromCloudUrlDirect(videoUrl: string, metadata: VideoMetadata): Promise<YouTubeUploadResult> {
|
|
230
|
+
try {
|
|
231
|
+
console.log(`📹 Using direct upload method for: ${videoUrl}`);
|
|
232
|
+
|
|
233
|
+
// First, get the video file as buffer
|
|
234
|
+
const videoResponse = await axios.get(videoUrl, {
|
|
235
|
+
responseType: 'arraybuffer',
|
|
236
|
+
timeout: 30000,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const videoBuffer = Buffer.from(videoResponse.data);
|
|
240
|
+
|
|
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"
|
|
248
|
+
},
|
|
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
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
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
|
+
);
|
|
276
|
+
|
|
277
|
+
const videoId = uploadResponse.data.id;
|
|
278
|
+
|
|
279
|
+
if (!videoId) {
|
|
280
|
+
throw new YouTubeUploadError('Video ID not returned from YouTube API');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
console.log(`✅ Video uploaded successfully via direct method: ${videoId}`);
|
|
284
|
+
|
|
285
|
+
// Wait for processing
|
|
286
|
+
await this.waitForVideoProcessingDirect(videoId);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
id: videoId,
|
|
290
|
+
status: 'processed',
|
|
291
|
+
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
292
|
+
title: metadata.title,
|
|
293
|
+
description: metadata.description,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
} catch (error: any) {
|
|
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}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Wait for processing using direct API calls
|
|
313
|
+
*/
|
|
314
|
+
private async waitForVideoProcessingDirect(videoId: string, timeoutMs: number = 300000): Promise<void> {
|
|
315
|
+
const startTime = Date.now();
|
|
316
|
+
let attempts = 0;
|
|
317
|
+
|
|
318
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
319
|
+
attempts++;
|
|
320
|
+
|
|
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
|
+
);
|
|
330
|
+
|
|
331
|
+
const video = response.data.items?.[0];
|
|
332
|
+
if (!video) {
|
|
333
|
+
throw new YouTubeProcessingError(`Video ${videoId} not found`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const status = video.status?.uploadStatus;
|
|
337
|
+
|
|
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
|
+
}
|
|
346
|
+
|
|
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));
|
|
351
|
+
|
|
352
|
+
} catch (error: any) {
|
|
353
|
+
console.warn(`Error checking video status (attempt ${attempts}):`, error.message);
|
|
354
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
throw new YouTubeTimeoutError(`Video processing timed out after ${timeoutMs}ms`);
|
|
359
|
+
}
|
|
360
|
+
}
|