@x12i/youtube-video-uploader-cli 1.2.0 → 1.3.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/update.js ADDED
@@ -0,0 +1,392 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { google } from 'googleapis';
4
+ import { loadMetadata } from './metadata.js';
5
+ import { UploadState } from './state.js';
6
+ import { authenticate } from './auth.js';
7
+
8
+ export const QUOTA_PER_VIDEO_UPDATE = 50;
9
+ export const QUOTA_PER_PLAYLIST_UPDATE = 50;
10
+ export const QUOTA_PER_LIST = 1;
11
+
12
+ /**
13
+ * Update snippet fields of a YouTube video while preserving other metadata.
14
+ *
15
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
16
+ * @param {string} videoId
17
+ * @param {object} snippetUpdates - Fields to update (title, description, tags, categoryId, etc.)
18
+ * @returns {Promise<{ videoId: string, title: string, data: any }>}
19
+ */
20
+ export async function updateVideoSnippet(youtube, videoId, snippetUpdates) {
21
+ if (!videoId) {
22
+ throw new Error('updateVideoSnippet requires a videoId');
23
+ }
24
+
25
+ // Fetch current snippet to preserve mandatory categoryId and other existing fields
26
+ const res = await youtube.videos.list({
27
+ part: ['snippet'],
28
+ id: [videoId],
29
+ });
30
+
31
+ const existing = res.data.items?.[0];
32
+ if (!existing || !existing.snippet) {
33
+ throw new Error(`Video not found or inaccessible: ${videoId}`);
34
+ }
35
+
36
+ const mergedSnippet = {
37
+ ...existing.snippet,
38
+ ...snippetUpdates,
39
+ categoryId: String(snippetUpdates.categoryId || existing.snippet.categoryId || '10'),
40
+ };
41
+
42
+ const updateRes = await youtube.videos.update({
43
+ part: ['snippet'],
44
+ requestBody: {
45
+ id: videoId,
46
+ snippet: mergedSnippet,
47
+ },
48
+ });
49
+
50
+ return {
51
+ videoId,
52
+ title: updateRes.data.snippet?.title || mergedSnippet.title,
53
+ data: updateRes.data,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Update a YouTube video's title.
59
+ *
60
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
61
+ * @param {string} videoId
62
+ * @param {string} newTitle
63
+ * @returns {Promise<{ videoId: string, title: string, data: any }>}
64
+ */
65
+ export async function updateVideoTitle(youtube, videoId, newTitle) {
66
+ return updateVideoSnippet(youtube, videoId, { title: newTitle });
67
+ }
68
+
69
+ /**
70
+ * Update snippet fields of a YouTube playlist while preserving existing description/tags.
71
+ *
72
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
73
+ * @param {string} playlistId
74
+ * @param {object} snippetUpdates
75
+ * @returns {Promise<{ playlistId: string, title: string, data: any }>}
76
+ */
77
+ export async function updatePlaylistSnippet(youtube, playlistId, snippetUpdates) {
78
+ if (!playlistId) {
79
+ throw new Error('updatePlaylistSnippet requires a playlistId');
80
+ }
81
+
82
+ const res = await youtube.playlists.list({
83
+ part: ['snippet'],
84
+ id: [playlistId],
85
+ });
86
+
87
+ const existing = res.data.items?.[0];
88
+ if (!existing || !existing.snippet) {
89
+ throw new Error(`Playlist not found or inaccessible: ${playlistId}`);
90
+ }
91
+
92
+ const mergedSnippet = {
93
+ ...existing.snippet,
94
+ ...snippetUpdates,
95
+ };
96
+
97
+ const updateRes = await youtube.playlists.update({
98
+ part: ['snippet'],
99
+ requestBody: {
100
+ id: playlistId,
101
+ snippet: mergedSnippet,
102
+ },
103
+ });
104
+
105
+ return {
106
+ playlistId,
107
+ title: updateRes.data.snippet?.title || mergedSnippet.title,
108
+ data: updateRes.data,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Update a YouTube playlist's title.
114
+ *
115
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
116
+ * @param {string} playlistId
117
+ * @param {string} newTitle
118
+ * @returns {Promise<{ playlistId: string, title: string, data: any }>}
119
+ */
120
+ export async function updatePlaylistTitle(youtube, playlistId, newTitle) {
121
+ return updatePlaylistSnippet(youtube, playlistId, { title: newTitle });
122
+ }
123
+
124
+ /**
125
+ * Batch update titles / metadata for all videos and the playlist in a target directory.
126
+ *
127
+ * @param {string} targetFolder
128
+ * @param {object} [options]
129
+ * @param {string} [options.titlePostfix] - Postfix to append to video titles
130
+ * @param {string} [options.titlePrefix] - Prefix to prepend to video titles
131
+ * @param {string} [options.playlistPostfix] - Postfix to append to playlist title
132
+ * @param {string} [options.playlistTitle] - Override playlist title
133
+ * @param {string} [options.replacePattern] - Regex or substring to find in titles
134
+ * @param {string} [options.replaceWith] - Replacement string
135
+ * @param {string} [options.metadataFile] - Custom path to metadata.json
136
+ * @param {boolean} [options.updateLocalMetadata=true] - Update metadata.json on disk
137
+ * @param {boolean} [options.syncMetadata=false] - Push titles from metadata.json directly to YouTube
138
+ * @param {string} [options.singleVideoId] - Optional single video ID to target
139
+ * @param {string} [options.singleVideoTitle] - Optional single video title override
140
+ * @param {string} [options.singlePlaylistId] - Optional single playlist ID to target
141
+ * @param {boolean} [options.dryRun=false]
142
+ * @param {string} [options.tokenFile]
143
+ * @param {string} [options.clientSecretsFile]
144
+ * @param {string} [options.clientId]
145
+ * @param {string} [options.clientSecret]
146
+ * @param {(event: { type: string, [key: string]: any }) => void} [options.onEvent]
147
+ * @returns {Promise<{
148
+ * playlist?: { playlistId: string, oldTitle: string, newTitle: string, changed: boolean },
149
+ * videos: Array<{ filename: string, videoId: string, oldTitle: string, newTitle: string, changed: boolean }>,
150
+ * estimatedQuota: number,
151
+ * dryRun: boolean
152
+ * }>}
153
+ */
154
+ export async function batchUpdateMetadata(targetFolder, options = {}) {
155
+ const resolvedFolder = path.resolve(targetFolder || process.cwd());
156
+ const dryRun = Boolean(options.dryRun);
157
+ const onEvent = options.onEvent || (() => {});
158
+ const updateLocal = options.updateLocalMetadata !== false;
159
+
160
+ const metadataPath = options.metadataFile
161
+ ? path.resolve(options.metadataFile)
162
+ : path.join(resolvedFolder, 'metadata.json');
163
+
164
+ let rawMetadata = null;
165
+ if (fs.existsSync(metadataPath)) {
166
+ try {
167
+ rawMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
168
+ } catch {
169
+ // Non-fatal if metadata.json is absent or invalid
170
+ }
171
+ }
172
+
173
+ const state = new UploadState(resolvedFolder);
174
+ const playlistKey = rawMetadata?.playlist?.key || 'default';
175
+ const cachedPlaylist = state.getPlaylist(playlistKey);
176
+ const playlistId = options.singlePlaylistId || rawMetadata?.playlistId || rawMetadata?.playlist?.playlistId || cachedPlaylist?.playlistId || state.getPlaylist('gray_to_light')?.playlistId || null;
177
+
178
+ // Build playlist plan
179
+ let playlistPlan = null;
180
+ if (playlistId) {
181
+ let currentTitle = rawMetadata?.playlist?.youtube?.snippet?.title || rawMetadata?.playlist?.snippet?.title || '';
182
+ let newTitle = currentTitle;
183
+
184
+ if (options.syncMetadata) {
185
+ newTitle = currentTitle;
186
+ } else if (options.playlistTitle) {
187
+ newTitle = options.playlistTitle;
188
+ } else if (options.playlistPostfix) {
189
+ if (currentTitle && !currentTitle.endsWith(options.playlistPostfix)) {
190
+ newTitle = currentTitle + options.playlistPostfix;
191
+ }
192
+ }
193
+
194
+ if (options.replacePattern) {
195
+ const reg = new RegExp(options.replacePattern, 'g');
196
+ newTitle = newTitle.replace(reg, options.replaceWith || '');
197
+ }
198
+
199
+ if (currentTitle || newTitle || options.playlistPostfix) {
200
+ playlistPlan = {
201
+ playlistId,
202
+ oldTitle: currentTitle,
203
+ newTitle: newTitle || currentTitle,
204
+ changed: (newTitle !== currentTitle) && Boolean(newTitle),
205
+ };
206
+ }
207
+ }
208
+
209
+ // Build video plans
210
+ const videoPlans = [];
211
+ const songsObj = rawMetadata?.songs || {};
212
+ const orderedKeys = Array.isArray(rawMetadata?.playlist?.orderedSongKeys)
213
+ ? rawMetadata.playlist.orderedSongKeys
214
+ : Object.keys(songsObj);
215
+
216
+ const seenFiles = new Set();
217
+
218
+ if (options.singleVideoId) {
219
+ videoPlans.push({
220
+ filename: path.basename(resolvedFolder),
221
+ videoId: options.singleVideoId,
222
+ oldTitle: '',
223
+ newTitle: options.singleVideoTitle || '',
224
+ changed: true,
225
+ });
226
+ } else {
227
+ for (const filename of orderedKeys) {
228
+ seenFiles.add(filename);
229
+ const songData = songsObj[filename] || {};
230
+ const uploadRecord = state.getUpload(filename);
231
+ const videoId = uploadRecord?.videoId || songData.playlistItem?.videoId;
232
+ if (!videoId) continue;
233
+
234
+ let currentTitle = songData.youtube?.snippet?.title || path.basename(filename, path.extname(filename));
235
+ let newTitle = currentTitle;
236
+
237
+ if (options.syncMetadata) {
238
+ newTitle = currentTitle;
239
+ } else {
240
+ if (options.titlePostfix) {
241
+ if (!currentTitle.endsWith(options.titlePostfix)) {
242
+ newTitle = currentTitle + options.titlePostfix;
243
+ }
244
+ }
245
+ if (options.titlePrefix) {
246
+ if (!newTitle.startsWith(options.titlePrefix)) {
247
+ newTitle = options.titlePrefix + newTitle;
248
+ }
249
+ }
250
+ }
251
+
252
+ if (options.replacePattern) {
253
+ const reg = new RegExp(options.replacePattern, 'g');
254
+ newTitle = newTitle.replace(reg, options.replaceWith || '');
255
+ }
256
+
257
+ videoPlans.push({
258
+ filename,
259
+ videoId,
260
+ oldTitle: currentTitle,
261
+ newTitle,
262
+ changed: (newTitle !== currentTitle),
263
+ });
264
+ }
265
+
266
+ // Check any additional uploads in state file
267
+ const stateUploads = state.listUploads();
268
+ for (const upload of stateUploads) {
269
+ if (!seenFiles.has(upload.filename) && upload.videoId) {
270
+ let currentTitle = path.basename(upload.filename, path.extname(upload.filename));
271
+ let newTitle = currentTitle;
272
+ if (!options.syncMetadata) {
273
+ if (options.titlePostfix && !currentTitle.endsWith(options.titlePostfix)) {
274
+ newTitle = currentTitle + options.titlePostfix;
275
+ }
276
+ if (options.titlePrefix && !newTitle.startsWith(options.titlePrefix)) {
277
+ newTitle = options.titlePrefix + newTitle;
278
+ }
279
+ }
280
+ if (options.replacePattern) {
281
+ const reg = new RegExp(options.replacePattern, 'g');
282
+ newTitle = newTitle.replace(reg, options.replaceWith || '');
283
+ }
284
+ videoPlans.push({
285
+ filename: upload.filename,
286
+ videoId: upload.videoId,
287
+ oldTitle: currentTitle,
288
+ newTitle,
289
+ changed: (newTitle !== currentTitle),
290
+ });
291
+ }
292
+ }
293
+ }
294
+
295
+ const changedVideos = videoPlans.filter(v => v.changed);
296
+ const playlistChanged = Boolean(playlistPlan && playlistPlan.changed);
297
+ const estimatedQuota = (changedVideos.length * (QUOTA_PER_VIDEO_UPDATE + QUOTA_PER_LIST)) +
298
+ (playlistChanged ? (QUOTA_PER_PLAYLIST_UPDATE + QUOTA_PER_LIST) : 0);
299
+
300
+ onEvent({
301
+ type: 'update_plan',
302
+ playlist: playlistPlan,
303
+ videos: videoPlans,
304
+ changedCount: changedVideos.length + (playlistChanged ? 1 : 0),
305
+ estimatedQuota,
306
+ dryRun,
307
+ });
308
+
309
+ if (dryRun) {
310
+ return {
311
+ playlist: playlistPlan,
312
+ videos: videoPlans,
313
+ estimatedQuota,
314
+ dryRun: true,
315
+ };
316
+ }
317
+
318
+ if (changedVideos.length === 0 && !playlistChanged) {
319
+ return {
320
+ playlist: playlistPlan,
321
+ videos: videoPlans,
322
+ estimatedQuota: 0,
323
+ dryRun: false,
324
+ };
325
+ }
326
+
327
+ // Authenticate
328
+ const auth = await authenticate({
329
+ tokenFile: options.tokenFile,
330
+ clientSecretsFile: options.clientSecretsFile,
331
+ clientId: options.clientId,
332
+ clientSecret: options.clientSecret,
333
+ });
334
+
335
+ const youtube = google.youtube({ version: 'v3', auth });
336
+
337
+ // Update Playlist
338
+ if (playlistPlan && playlistPlan.changed) {
339
+ onEvent({ type: 'playlist_update_start', playlistId: playlistPlan.playlistId, newTitle: playlistPlan.newTitle });
340
+ try {
341
+ await updatePlaylistTitle(youtube, playlistPlan.playlistId, playlistPlan.newTitle);
342
+ onEvent({ type: 'playlist_update_success', playlistId: playlistPlan.playlistId, newTitle: playlistPlan.newTitle });
343
+ } catch (err) {
344
+ onEvent({ type: 'playlist_update_error', playlistId: playlistPlan.playlistId, error: err });
345
+ throw new Error(`Failed to update playlist title: ${err.message}`);
346
+ }
347
+ }
348
+
349
+ // Update Videos
350
+ for (let i = 0; i < changedVideos.length; i++) {
351
+ const v = changedVideos[i];
352
+ onEvent({ type: 'video_update_start', item: v, index: i, total: changedVideos.length });
353
+ try {
354
+ await updateVideoTitle(youtube, v.videoId, v.newTitle);
355
+ onEvent({ type: 'video_update_success', item: v, index: i, total: changedVideos.length });
356
+ } catch (err) {
357
+ onEvent({ type: 'video_update_error', item: v, error: err });
358
+ throw new Error(`Failed to update video "${v.filename}" (${v.videoId}): ${err.message}`);
359
+ }
360
+ }
361
+
362
+ // Update metadata.json on disk if present and requested
363
+ if (updateLocal && rawMetadata && fs.existsSync(metadataPath)) {
364
+ try {
365
+ if (playlistPlan && playlistPlan.changed) {
366
+ if (rawMetadata.playlist?.youtube?.snippet) {
367
+ rawMetadata.playlist.youtube.snippet.title = playlistPlan.newTitle;
368
+ } else if (rawMetadata.playlist?.snippet) {
369
+ rawMetadata.playlist.snippet.title = playlistPlan.newTitle;
370
+ }
371
+ }
372
+
373
+ for (const v of videoPlans) {
374
+ if (v.changed && rawMetadata.songs?.[v.filename]?.youtube?.snippet) {
375
+ rawMetadata.songs[v.filename].youtube.snippet.title = v.newTitle;
376
+ }
377
+ }
378
+
379
+ fs.writeFileSync(metadataPath, JSON.stringify(rawMetadata, null, 2) + '\n', 'utf-8');
380
+ onEvent({ type: 'metadata_file_saved', metadataPath });
381
+ } catch (err) {
382
+ onEvent({ type: 'metadata_file_error', error: err });
383
+ }
384
+ }
385
+
386
+ return {
387
+ playlist: playlistPlan,
388
+ videos: videoPlans,
389
+ estimatedQuota,
390
+ dryRun: false,
391
+ };
392
+ }
package/src/uploader.js CHANGED
@@ -4,6 +4,7 @@ import { google } from 'googleapis';
4
4
  import { loadMetadata } from './metadata.js';
5
5
  import { UploadState } from './state.js';
6
6
  import { authenticate } from './auth.js';
7
+ import { resolveMediaForMetadata } from './media/resolve.js';
7
8
 
8
9
  export const QUOTA_PER_VIDEO_UPLOAD = 1600;
9
10
  export const QUOTA_PER_PLAYLIST_ITEM = 50;
@@ -134,6 +135,9 @@ export async function addToPlaylist(youtube, playlistId, videoId, position) {
134
135
  * @param {string} [options.metadataFile] - Custom metadata JSON path
135
136
  * @param {boolean} [options.force=false] - Re-upload previously uploaded videos
136
137
  * @param {boolean} [options.dryRun=false] - Preview without uploading
138
+ * @param {number} [options.limit] - Max videos to upload in this run
139
+ * @param {boolean} [options.safeQuota=false] - Cap batch under ~9,950 daily units
140
+ * @param {number} [options.maxQuota] - Hard quota unit cap for this run
137
141
  * @param {string} [options.tokenFile] - Custom token JSON path
138
142
  * @param {string} [options.clientSecretsFile] - Custom client_secret.json path
139
143
  * @param {(event: { type: string, [key: string]: any }) => void} [options.onEvent]
@@ -142,6 +146,7 @@ export async function addToPlaylist(youtube, playlistId, videoId, position) {
142
146
  * total: number,
143
147
  * uploaded: Array<{ filename: string, videoId: string, url: string, addedToPlaylist: boolean }>,
144
148
  * skipped: Array<{ filename: string, videoId?: string, reason: string }>,
149
+ * deferred: Array<{ filename: string, reason: string }>,
145
150
  * failed: Array<{ filename: string, error: Error }>,
146
151
  * estimatedQuota: number,
147
152
  * playlistId?: string | null
@@ -153,6 +158,20 @@ export async function batchUpload(targetFolder, options = {}) {
153
158
  const dryRun = Boolean(options.dryRun);
154
159
  const onEvent = options.onEvent || (() => {});
155
160
 
161
+ if (options.autoMedia) {
162
+ onEvent({ type: 'auto_media_start' });
163
+ await resolveMediaForMetadata(resolvedFolder, {
164
+ compose: true,
165
+ provider: options.mediaProvider,
166
+ force: options.force,
167
+ dryRun: options.dryRun,
168
+ pexelsKey: options.pexelsKey,
169
+ pixabayKey: options.pixabayKey,
170
+ onEvent,
171
+ });
172
+ onEvent({ type: 'auto_media_complete' });
173
+ }
174
+
156
175
  const config = loadMetadata(resolvedFolder, options.metadataFile);
157
176
  const state = new UploadState(resolvedFolder);
158
177
 
@@ -369,6 +388,7 @@ export async function batchUpload(targetFolder, options = {}) {
369
388
  total: config.videos.length,
370
389
  uploaded,
371
390
  skipped,
391
+ deferred,
372
392
  failed,
373
393
  estimatedQuota,
374
394
  playlistId,