@x12i/youtube-video-uploader-cli 1.0.0 → 1.2.0

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.
package/src/uploader.js CHANGED
@@ -7,8 +7,36 @@ import { authenticate } from './auth.js';
7
7
 
8
8
  export const QUOTA_PER_VIDEO_UPLOAD = 1600;
9
9
  export const QUOTA_PER_PLAYLIST_ITEM = 50;
10
+ export const QUOTA_PER_PLAYLIST_CREATE = 50;
10
11
  export const DAILY_DEFAULT_QUOTA = 10000;
11
12
 
13
+ import { interpolateTemplates } from './metadata.js';
14
+
15
+ /**
16
+ * Create a YouTube Playlist.
17
+ *
18
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
19
+ * @param {object} snippet
20
+ * @param {object} [status={ privacyStatus: 'private' }]
21
+ * @returns {Promise<{ playlistId: string, url: string, data: any }>}
22
+ */
23
+ export async function createPlaylist(youtube, snippet, status = { privacyStatus: 'private' }) {
24
+ const res = await youtube.playlists.insert({
25
+ part: ['snippet', 'status'],
26
+ requestBody: {
27
+ snippet,
28
+ status,
29
+ },
30
+ });
31
+
32
+ const playlistId = res.data.id;
33
+ return {
34
+ playlistId,
35
+ url: `https://www.youtube.com/playlist?list=${playlistId}`,
36
+ data: res.data,
37
+ };
38
+ }
39
+
12
40
  /**
13
41
  * Upload a single video file to YouTube via YouTube Data API v3.
14
42
  *
@@ -29,23 +57,37 @@ export async function uploadVideo(youtube, filePath, meta, defaultPrivacy = 'pri
29
57
  throw new Error(`Video file is empty (0 bytes): ${filePath}`);
30
58
  }
31
59
 
32
- const res = await youtube.videos.insert({
60
+ const snippet = {
61
+ title: meta.title,
62
+ description: meta.description || '',
63
+ tags: meta.tags || [],
64
+ categoryId: String(meta.categoryId || defaultCategory || '10'),
65
+ };
66
+
67
+ if (meta.defaultLanguage) snippet.defaultLanguage = meta.defaultLanguage;
68
+ if (meta.defaultAudioLanguage) snippet.defaultAudioLanguage = meta.defaultAudioLanguage;
69
+
70
+ const status = {
71
+ privacyStatus: meta.privacyStatus || defaultPrivacy || 'private',
72
+ license: meta.license || 'youtube',
73
+ embeddable: meta.embeddable !== undefined ? meta.embeddable : true,
74
+ publicStatsViewable: meta.publicStatsViewable !== undefined ? meta.publicStatsViewable : true,
75
+ selfDeclaredMadeForKids: meta.selfDeclaredMadeForKids !== undefined ? meta.selfDeclaredMadeForKids : false,
76
+ };
77
+
78
+ const insertParams = {
33
79
  part: ['snippet', 'status'],
80
+ notifySubscribers: meta.notifySubscribers !== undefined ? meta.notifySubscribers : false,
34
81
  requestBody: {
35
- snippet: {
36
- title: meta.title,
37
- description: meta.description || '',
38
- tags: meta.tags || [],
39
- categoryId: meta.categoryId || defaultCategory || '10',
40
- },
41
- status: {
42
- privacyStatus: meta.privacyStatus || defaultPrivacy || 'private',
43
- },
82
+ snippet,
83
+ status,
44
84
  },
45
85
  media: {
46
86
  body: fs.createReadStream(filePath),
47
87
  },
48
- });
88
+ };
89
+
90
+ const res = await youtube.videos.insert(insertParams);
49
91
 
50
92
  const videoId = res.data.id;
51
93
  return {
@@ -60,19 +102,25 @@ export async function uploadVideo(youtube, filePath, meta, defaultPrivacy = 'pri
60
102
  * @param {import('googleapis').youtube_v3.Youtube} youtube
61
103
  * @param {string} playlistId
62
104
  * @param {string} videoId
105
+ * @param {number} [position]
63
106
  * @returns {Promise<any>}
64
107
  */
65
- export async function addToPlaylist(youtube, playlistId, videoId) {
108
+ export async function addToPlaylist(youtube, playlistId, videoId, position) {
109
+ const snippet = {
110
+ playlistId,
111
+ resourceId: {
112
+ kind: 'youtube#video',
113
+ videoId,
114
+ },
115
+ };
116
+ if (typeof position === 'number') {
117
+ snippet.position = position;
118
+ }
119
+
66
120
  const res = await youtube.playlistItems.insert({
67
121
  part: ['snippet'],
68
122
  requestBody: {
69
- snippet: {
70
- playlistId,
71
- resourceId: {
72
- kind: 'youtube#video',
73
- videoId,
74
- },
75
- },
123
+ snippet,
76
124
  },
77
125
  });
78
126
  return res.data;
@@ -95,7 +143,8 @@ export async function addToPlaylist(youtube, playlistId, videoId) {
95
143
  * uploaded: Array<{ filename: string, videoId: string, url: string, addedToPlaylist: boolean }>,
96
144
  * skipped: Array<{ filename: string, videoId?: string, reason: string }>,
97
145
  * failed: Array<{ filename: string, error: Error }>,
98
- * estimatedQuota: number
146
+ * estimatedQuota: number,
147
+ * playlistId?: string | null
99
148
  * }>}
100
149
  */
101
150
  export async function batchUpload(targetFolder, options = {}) {
@@ -107,7 +156,11 @@ export async function batchUpload(targetFolder, options = {}) {
107
156
  const config = loadMetadata(resolvedFolder, options.metadataFile);
108
157
  const state = new UploadState(resolvedFolder);
109
158
 
110
- const playlistId = config.playlistId;
159
+ const playlistKey = config.playlist?.key || 'default';
160
+ const cachedPlaylist = state.getPlaylist(playlistKey);
161
+ let playlistId = config.playlistId || cachedPlaylist?.playlistId || null;
162
+ const needsPlaylistCreation = !playlistId && Boolean(config.playlist?.snippet);
163
+
111
164
  const toProcess = [];
112
165
  const skipped = [];
113
166
 
@@ -132,16 +185,53 @@ export async function batchUpload(targetFolder, options = {}) {
132
185
  }
133
186
  }
134
187
 
135
- const estimatedQuota = (toProcess.length * QUOTA_PER_VIDEO_UPLOAD) +
136
- (playlistId ? toProcess.length * QUOTA_PER_PLAYLIST_ITEM : 0);
188
+ const willHavePlaylist = Boolean(playlistId || needsPlaylistCreation);
189
+ const baseQuota = needsPlaylistCreation ? QUOTA_PER_PLAYLIST_CREATE : 0;
190
+ const costPerVideo = QUOTA_PER_VIDEO_UPLOAD + (willHavePlaylist ? QUOTA_PER_PLAYLIST_ITEM : 0);
191
+
192
+ let limit = Infinity;
193
+ if (options.limit !== undefined && options.limit !== null && !isNaN(options.limit)) {
194
+ limit = Math.max(0, parseInt(options.limit, 10));
195
+ }
196
+
197
+ let maxQuota = Infinity;
198
+ if (options.maxQuota !== undefined && options.maxQuota !== null && !isNaN(options.maxQuota)) {
199
+ maxQuota = parseInt(options.maxQuota, 10);
200
+ } else if (options.safeQuota) {
201
+ maxQuota = DAILY_DEFAULT_QUOTA - 50; // 9,950 units safe limit
202
+ }
203
+
204
+ const activeToProcess = [];
205
+ const deferred = [];
206
+
207
+ for (const item of toProcess) {
208
+ const candidateQuota = baseQuota + ((activeToProcess.length + 1) * costPerVideo);
209
+ if (activeToProcess.length < limit && candidateQuota <= maxQuota) {
210
+ activeToProcess.push(item);
211
+ } else {
212
+ deferred.push({
213
+ filename: item.filename,
214
+ reason: activeToProcess.length >= limit
215
+ ? `Deferred by upload limit (${limit})`
216
+ : `Deferred for next batch to stay under quota limit (${maxQuota} units)`,
217
+ });
218
+ }
219
+ }
220
+
221
+ const estimatedQuota = baseQuota + (activeToProcess.length * costPerVideo);
137
222
 
138
223
  onEvent({
139
224
  type: 'plan',
140
225
  total: config.videos.length,
141
- toProcess,
226
+ toProcess: activeToProcess,
142
227
  skipped,
228
+ deferred,
143
229
  estimatedQuota,
144
230
  playlistId,
231
+ needsPlaylistCreation,
232
+ playlist: config.playlist,
233
+ safeQuota: Boolean(options.safeQuota),
234
+ limit: isFinite(limit) ? limit : null,
145
235
  });
146
236
 
147
237
  if (dryRun) {
@@ -150,19 +240,23 @@ export async function batchUpload(targetFolder, options = {}) {
150
240
  total: config.videos.length,
151
241
  uploaded: [],
152
242
  skipped,
243
+ deferred,
153
244
  failed: [],
154
245
  estimatedQuota,
246
+ playlistId,
155
247
  };
156
248
  }
157
249
 
158
- if (toProcess.length === 0) {
250
+ if (activeToProcess.length === 0 && !needsPlaylistCreation) {
159
251
  return {
160
252
  targetFolder: resolvedFolder,
161
253
  total: config.videos.length,
162
254
  uploaded: [],
163
255
  skipped,
256
+ deferred,
164
257
  failed: [],
165
258
  estimatedQuota: 0,
259
+ playlistId,
166
260
  };
167
261
  }
168
262
 
@@ -176,12 +270,61 @@ export async function batchUpload(targetFolder, options = {}) {
176
270
 
177
271
  const youtube = google.youtube({ version: 'v3', auth });
178
272
 
273
+ // Verify and display authenticated channel identity
274
+ try {
275
+ const channelRes = await youtube.channels.list({
276
+ part: ['snippet'],
277
+ mine: true,
278
+ });
279
+ if (channelRes.data.items && channelRes.data.items.length > 0) {
280
+ const ch = channelRes.data.items[0];
281
+ const channelInfo = {
282
+ id: ch.id,
283
+ title: ch.snippet?.title || 'Unknown',
284
+ customUrl: ch.snippet?.customUrl || null,
285
+ };
286
+ onEvent({ type: 'authenticated_channel', channel: channelInfo });
287
+ }
288
+ } catch {
289
+ // Non-fatal
290
+ }
291
+
292
+ // Create playlist if needed
293
+ if (needsPlaylistCreation && config.playlist?.snippet) {
294
+ onEvent({ type: 'playlist_create_start', playlist: config.playlist });
295
+ try {
296
+ const created = await createPlaylist(
297
+ youtube,
298
+ config.playlist.snippet,
299
+ config.playlist.status || { privacyStatus: config.privacyStatus }
300
+ );
301
+ playlistId = created.playlistId;
302
+ state.recordPlaylistInfo(playlistKey, created.playlistId, created.url);
303
+ onEvent({ type: 'playlist_create_success', playlistId, url: created.url });
304
+ } catch (err) {
305
+ onEvent({ type: 'playlist_create_error', error: err });
306
+ throw new Error(`Failed to create YouTube Playlist: ${err.message}`);
307
+ }
308
+ }
309
+
310
+ // Apply playlistUrl template interpolation to pending video items
311
+ const playlistUrl = playlistId ? `https://www.youtube.com/playlist?list=${playlistId}` : '';
312
+ const finalTemplateVars = {
313
+ ...config.templateVariables,
314
+ playlistUrl: playlistUrl || config.templateVariables.playlistUrl || '',
315
+ };
316
+
317
+ for (const item of activeToProcess) {
318
+ item.title = interpolateTemplates(item.title, finalTemplateVars);
319
+ item.description = interpolateTemplates(item.description, finalTemplateVars);
320
+ }
321
+
179
322
  const uploaded = [];
180
323
  const failed = [];
181
324
 
182
- for (let i = 0; i < toProcess.length; i++) {
183
- const item = toProcess[i];
184
- onEvent({ type: 'upload_start', item, index: i, total: toProcess.length });
325
+ for (let i = 0; i < activeToProcess.length; i++) {
326
+ const item = activeToProcess[i];
327
+ onEvent({ type: 'upload_start', item, index: i, total: activeToProcess.length });
185
328
 
186
329
  try {
187
330
  const uploadRes = await uploadVideo(
@@ -196,7 +339,7 @@ export async function batchUpload(targetFolder, options = {}) {
196
339
  if (playlistId) {
197
340
  onEvent({ type: 'playlist_start', item, videoId: uploadRes.videoId, playlistId });
198
341
  try {
199
- await addToPlaylist(youtube, playlistId, uploadRes.videoId);
342
+ await addToPlaylist(youtube, playlistId, uploadRes.videoId, item.playlistPosition);
200
343
  addedToPlaylist = true;
201
344
  onEvent({ type: 'playlist_success', item, videoId: uploadRes.videoId, playlistId });
202
345
  } catch (playlistErr) {
@@ -228,5 +371,6 @@ export async function batchUpload(targetFolder, options = {}) {
228
371
  skipped,
229
372
  failed,
230
373
  estimatedQuota,
374
+ playlistId,
231
375
  };
232
376
  }