@x12i/youtube-video-uploader-cli 1.4.0 → 1.6.1

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 CHANGED
@@ -4,11 +4,50 @@ 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 { listPlaylistItems } from './playlist/reorder.js';
7
8
 
8
9
  export const QUOTA_PER_VIDEO_UPDATE = 50;
9
10
  export const QUOTA_PER_PLAYLIST_UPDATE = 50;
10
11
  export const QUOTA_PER_LIST = 1;
11
12
 
13
+ /**
14
+ * Apply a find/replace to a string.
15
+ * @param {string} text
16
+ * @param {string} pattern
17
+ * @param {string} [replacement]
18
+ * @param {boolean} [literal=false]
19
+ */
20
+ export function applyTextReplace(text, pattern, replacement = '', literal = false) {
21
+ if (!pattern || text == null) return text;
22
+ const value = String(text);
23
+ if (literal) {
24
+ return value.split(pattern).join(replacement);
25
+ }
26
+ return value.replace(new RegExp(pattern, 'g'), replacement);
27
+ }
28
+
29
+ /**
30
+ * Deep-replace matching strings anywhere in a JSON-serializable object.
31
+ * @param {unknown} value
32
+ * @param {string} pattern
33
+ * @param {string} [replacement]
34
+ * @param {boolean} [literal=false]
35
+ */
36
+ export function deepReplaceInObject(value, pattern, replacement = '', literal = false) {
37
+ if (typeof value === 'string') {
38
+ return applyTextReplace(value, pattern, replacement, literal);
39
+ }
40
+ if (Array.isArray(value)) {
41
+ return value.map((item) => deepReplaceInObject(item, pattern, replacement, literal));
42
+ }
43
+ if (value && typeof value === 'object') {
44
+ return Object.fromEntries(
45
+ Object.entries(value).map(([key, item]) => [key, deepReplaceInObject(item, pattern, replacement, literal)])
46
+ );
47
+ }
48
+ return value;
49
+ }
50
+
12
51
  /**
13
52
  * Update snippet fields of a YouTube video while preserving other metadata.
14
53
  *
@@ -132,6 +171,9 @@ export async function updatePlaylistTitle(youtube, playlistId, newTitle) {
132
171
  * @param {string} [options.playlistTitle] - Override playlist title
133
172
  * @param {string} [options.replacePattern] - Regex or substring to find in titles
134
173
  * @param {string} [options.replaceWith] - Replacement string
174
+ * @param {boolean} [options.replaceLiteral=false] - Treat replacePattern as a plain substring
175
+ * @param {boolean} [options.replaceInSnippet=false] - Also replace in descriptions/tags on YouTube
176
+ * @param {string} [options.includePlaylistId] - Include every video in this playlist (e.g. overview prefix videos)
135
177
  * @param {string} [options.metadataFile] - Custom path to metadata.json
136
178
  * @param {boolean} [options.updateLocalMetadata=true] - Update metadata.json on disk
137
179
  * @param {boolean} [options.syncMetadata=false] - Push titles from metadata.json directly to YouTube
@@ -156,6 +198,14 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
156
198
  const dryRun = Boolean(options.dryRun);
157
199
  const onEvent = options.onEvent || (() => {});
158
200
  const updateLocal = options.updateLocalMetadata !== false;
201
+ const replaceLiteral = Boolean(options.replaceLiteral);
202
+ const replaceInSnippet = Boolean(options.replaceInSnippet);
203
+ const includePlaylistId = options.includePlaylistId || null;
204
+
205
+ const replaceText = (text) => {
206
+ if (!options.replacePattern) return text;
207
+ return applyTextReplace(text, options.replacePattern, options.replaceWith || '', replaceLiteral);
208
+ };
159
209
 
160
210
  const metadataPath = options.metadataFile
161
211
  ? path.resolve(options.metadataFile)
@@ -175,10 +225,30 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
175
225
  const cachedPlaylist = state.getPlaylist(playlistKey);
176
226
  const playlistId = options.singlePlaylistId || rawMetadata?.playlistId || rawMetadata?.playlist?.playlistId || cachedPlaylist?.playlistId || state.getPlaylist('gray_to_light')?.playlistId || null;
177
227
 
228
+ let youtube = null;
229
+ const needsYoutubeForPlan = Boolean(includePlaylistId && options.replacePattern);
230
+ if (needsYoutubeForPlan) {
231
+ const auth = await authenticate({
232
+ tokenFile: options.tokenFile,
233
+ clientSecretsFile: options.clientSecretsFile,
234
+ clientId: options.clientId,
235
+ clientSecret: options.clientSecret,
236
+ });
237
+ youtube = google.youtube({ version: 'v3', auth });
238
+ }
239
+
178
240
  // Build playlist plan
179
241
  let playlistPlan = null;
180
242
  if (playlistId) {
181
243
  let currentTitle = rawMetadata?.playlist?.youtube?.snippet?.title || rawMetadata?.playlist?.snippet?.title || '';
244
+ if (!currentTitle && youtube) {
245
+ try {
246
+ const { data } = await youtube.playlists.list({ part: ['snippet'], id: [playlistId] });
247
+ currentTitle = data.items?.[0]?.snippet?.title || '';
248
+ } catch {
249
+ // Non-fatal during planning
250
+ }
251
+ }
182
252
  let newTitle = currentTitle;
183
253
 
184
254
  if (options.syncMetadata) {
@@ -192,8 +262,7 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
192
262
  }
193
263
 
194
264
  if (options.replacePattern) {
195
- const reg = new RegExp(options.replacePattern, 'g');
196
- newTitle = newTitle.replace(reg, options.replaceWith || '');
265
+ newTitle = replaceText(newTitle);
197
266
  }
198
267
 
199
268
  if (currentTitle || newTitle || options.playlistPostfix) {
@@ -207,6 +276,7 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
207
276
  }
208
277
 
209
278
  // Build video plans
279
+ /** @type {Array<{ filename: string, videoId: string, oldTitle: string, newTitle: string, changed: boolean, snippetUpdates?: object }>} */
210
280
  const videoPlans = [];
211
281
  const songsObj = rawMetadata?.songs || {};
212
282
  const orderedKeys = Array.isArray(rawMetadata?.playlist?.orderedSongKeys)
@@ -233,6 +303,9 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
233
303
 
234
304
  let currentTitle = songData.youtube?.snippet?.title || path.basename(filename, path.extname(filename));
235
305
  let newTitle = currentTitle;
306
+ const snippet = songData.youtube?.snippet || {};
307
+ let newDescription = snippet.description || '';
308
+ let newTags = Array.isArray(snippet.tags) ? [...snippet.tags] : [];
236
309
 
237
310
  if (options.syncMetadata) {
238
311
  newTitle = currentTitle;
@@ -250,8 +323,20 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
250
323
  }
251
324
 
252
325
  if (options.replacePattern) {
253
- const reg = new RegExp(options.replacePattern, 'g');
254
- newTitle = newTitle.replace(reg, options.replaceWith || '');
326
+ newTitle = replaceText(newTitle);
327
+ if (replaceInSnippet) {
328
+ newDescription = replaceText(newDescription);
329
+ newTags = newTags.map((tag) => replaceText(tag));
330
+ }
331
+ }
332
+
333
+ const snippetUpdates = {};
334
+ if (newTitle !== currentTitle) snippetUpdates.title = newTitle;
335
+ if (replaceInSnippet && newDescription !== (snippet.description || '')) {
336
+ snippetUpdates.description = newDescription;
337
+ }
338
+ if (replaceInSnippet && JSON.stringify(newTags) !== JSON.stringify(snippet.tags || [])) {
339
+ snippetUpdates.tags = newTags;
255
340
  }
256
341
 
257
342
  videoPlans.push({
@@ -259,7 +344,8 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
259
344
  videoId,
260
345
  oldTitle: currentTitle,
261
346
  newTitle,
262
- changed: (newTitle !== currentTitle),
347
+ changed: Object.keys(snippetUpdates).length > 0,
348
+ snippetUpdates: Object.keys(snippetUpdates).length > 0 ? snippetUpdates : undefined,
263
349
  });
264
350
  }
265
351
 
@@ -278,8 +364,7 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
278
364
  }
279
365
  }
280
366
  if (options.replacePattern) {
281
- const reg = new RegExp(options.replacePattern, 'g');
282
- newTitle = newTitle.replace(reg, options.replaceWith || '');
367
+ newTitle = replaceText(newTitle);
283
368
  }
284
369
  videoPlans.push({
285
370
  filename: upload.filename,
@@ -287,6 +372,41 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
287
372
  oldTitle: currentTitle,
288
373
  newTitle,
289
374
  changed: (newTitle !== currentTitle),
375
+ snippetUpdates: newTitle !== currentTitle ? { title: newTitle } : undefined,
376
+ });
377
+ }
378
+ }
379
+ }
380
+
381
+ if (includePlaylistId && youtube && options.replacePattern) {
382
+ const items = await listPlaylistItems(youtube, includePlaylistId);
383
+ const plannedIds = new Set(videoPlans.map((plan) => plan.videoId));
384
+ const missingIds = items.map((item) => item.videoId).filter((videoId) => videoId && !plannedIds.has(videoId));
385
+ for (let i = 0; i < missingIds.length; i += 50) {
386
+ const chunk = missingIds.slice(i, i + 50);
387
+ const { data } = await youtube.videos.list({ part: ['snippet'], id: chunk });
388
+ for (const item of data.items || []) {
389
+ const currentTitle = item.snippet?.title || '';
390
+ const newTitle = replaceText(currentTitle);
391
+ const snippetUpdates = {};
392
+ if (newTitle !== currentTitle) snippetUpdates.title = newTitle;
393
+ if (replaceInSnippet) {
394
+ const newDescription = replaceText(item.snippet?.description || '');
395
+ const newTags = (item.snippet?.tags || []).map((tag) => replaceText(tag));
396
+ if (newDescription !== (item.snippet?.description || '')) {
397
+ snippetUpdates.description = newDescription;
398
+ }
399
+ if (JSON.stringify(newTags) !== JSON.stringify(item.snippet?.tags || [])) {
400
+ snippetUpdates.tags = newTags;
401
+ }
402
+ }
403
+ videoPlans.push({
404
+ filename: item.id,
405
+ videoId: item.id,
406
+ oldTitle: currentTitle,
407
+ newTitle,
408
+ changed: Object.keys(snippetUpdates).length > 0,
409
+ snippetUpdates: Object.keys(snippetUpdates).length > 0 ? snippetUpdates : undefined,
290
410
  });
291
411
  }
292
412
  }
@@ -325,14 +445,15 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
325
445
  }
326
446
 
327
447
  // 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 });
448
+ if (!youtube) {
449
+ const auth = await authenticate({
450
+ tokenFile: options.tokenFile,
451
+ clientSecretsFile: options.clientSecretsFile,
452
+ clientId: options.clientId,
453
+ clientSecret: options.clientSecret,
454
+ });
455
+ youtube = google.youtube({ version: 'v3', auth });
456
+ }
336
457
 
337
458
  // Update Playlist
338
459
  if (playlistPlan && playlistPlan.changed) {
@@ -351,7 +472,11 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
351
472
  const v = changedVideos[i];
352
473
  onEvent({ type: 'video_update_start', item: v, index: i, total: changedVideos.length });
353
474
  try {
354
- await updateVideoTitle(youtube, v.videoId, v.newTitle);
475
+ if (v.snippetUpdates && Object.keys(v.snippetUpdates).length > 0) {
476
+ await updateVideoSnippet(youtube, v.videoId, v.snippetUpdates);
477
+ } else {
478
+ await updateVideoTitle(youtube, v.videoId, v.newTitle);
479
+ }
355
480
  onEvent({ type: 'video_update_success', item: v, index: i, total: changedVideos.length });
356
481
  } catch (err) {
357
482
  onEvent({ type: 'video_update_error', item: v, error: err });
@@ -362,17 +487,32 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
362
487
  // Update metadata.json on disk if present and requested
363
488
  if (updateLocal && rawMetadata && fs.existsSync(metadataPath)) {
364
489
  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;
490
+ if (options.replacePattern) {
491
+ rawMetadata = deepReplaceInObject(
492
+ rawMetadata,
493
+ options.replacePattern,
494
+ options.replaceWith || '',
495
+ replaceLiteral
496
+ );
497
+ } else {
498
+ if (playlistPlan && playlistPlan.changed) {
499
+ if (rawMetadata.playlist?.youtube?.snippet) {
500
+ rawMetadata.playlist.youtube.snippet.title = playlistPlan.newTitle;
501
+ } else if (rawMetadata.playlist?.snippet) {
502
+ rawMetadata.playlist.snippet.title = playlistPlan.newTitle;
503
+ }
370
504
  }
371
- }
372
505
 
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;
506
+ for (const v of videoPlans) {
507
+ if (v.changed && rawMetadata.songs?.[v.filename]?.youtube?.snippet) {
508
+ rawMetadata.songs[v.filename].youtube.snippet.title = v.newTitle;
509
+ if (v.snippetUpdates?.description) {
510
+ rawMetadata.songs[v.filename].youtube.snippet.description = v.snippetUpdates.description;
511
+ }
512
+ if (v.snippetUpdates?.tags) {
513
+ rawMetadata.songs[v.filename].youtube.snippet.tags = v.snippetUpdates.tags;
514
+ }
515
+ }
376
516
  }
377
517
  }
378
518