@x12i/youtube-video-uploader-cli 1.3.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.
@@ -0,0 +1,201 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export const PILIO_PROVIDER_ID = 'pilio';
7
+ export const PILIO_LICENSE = 'MIT';
8
+ export const PILIO_PACKAGE = '@pilio/gemini-watermark-remover';
9
+
10
+ /**
11
+ * Locate the installed package root and gwr binary path.
12
+ * @returns {{ root: string, binPath: string }}
13
+ */
14
+ export function resolvePilioBin() {
15
+ let entryUrl;
16
+ try {
17
+ entryUrl = import.meta.resolve(PILIO_PACKAGE);
18
+ } catch (err) {
19
+ throw new Error(
20
+ `Watermark provider "${PILIO_PROVIDER_ID}" requires ${PILIO_PACKAGE} (license: ${PILIO_LICENSE}). ` +
21
+ `Install it with: npm install ${PILIO_PACKAGE} sharp\n` +
22
+ `Original error: ${err.message}`
23
+ );
24
+ }
25
+
26
+ const entryPath = fileURLToPath(entryUrl);
27
+ let dir = path.dirname(entryPath);
28
+ while (dir !== path.dirname(dir)) {
29
+ const pkgPath = path.join(dir, 'package.json');
30
+ if (fs.existsSync(pkgPath)) {
31
+ try {
32
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
33
+ if (pkg.name === PILIO_PACKAGE) {
34
+ const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.gwr;
35
+ if (!binRel) {
36
+ throw new Error(`${PILIO_PACKAGE} package.json is missing a "gwr" bin entry`);
37
+ }
38
+ const binPath = path.resolve(dir, binRel);
39
+ if (!fs.existsSync(binPath)) {
40
+ throw new Error(`gwr binary not found at ${binPath}`);
41
+ }
42
+ return { root: dir, binPath };
43
+ }
44
+ } catch (err) {
45
+ if (err.message.includes('gwr binary') || err.message.includes('bin entry')) {
46
+ throw err;
47
+ }
48
+ }
49
+ }
50
+ dir = path.dirname(dir);
51
+ }
52
+
53
+ throw new Error(
54
+ `Could not locate ${PILIO_PACKAGE} package root after resolving ${entryPath}. ` +
55
+ `Reinstall with: npm install ${PILIO_PACKAGE} sharp`
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Build the gwr argv for a remove operation.
61
+ *
62
+ * @param {object} options
63
+ * @param {string} options.inputPath
64
+ * @param {string} [options.outputPath]
65
+ * @param {string} [options.outDir]
66
+ * @param {boolean} [options.overwrite=false]
67
+ * @param {boolean} [options.json=false]
68
+ * @param {number} [options.videoBitrateMbps]
69
+ * @returns {string[]}
70
+ */
71
+ export function buildPilioArgs(options = {}) {
72
+ const {
73
+ inputPath,
74
+ outputPath,
75
+ outDir,
76
+ overwrite = false,
77
+ json = false,
78
+ videoBitrateMbps,
79
+ } = options;
80
+
81
+ if (!inputPath) {
82
+ throw new Error('inputPath is required for pilio watermark removal');
83
+ }
84
+
85
+ const args = ['remove', inputPath];
86
+
87
+ if (outDir) {
88
+ args.push('--out-dir', outDir);
89
+ } else if (outputPath) {
90
+ args.push('--output', outputPath);
91
+ } else {
92
+ throw new Error('Either outputPath or outDir is required for pilio watermark removal');
93
+ }
94
+
95
+ if (overwrite) {
96
+ args.push('--overwrite');
97
+ }
98
+ if (json) {
99
+ args.push('--json');
100
+ }
101
+ if (videoBitrateMbps !== undefined && videoBitrateMbps !== null && videoBitrateMbps !== '') {
102
+ const n = Number(videoBitrateMbps);
103
+ if (!Number.isFinite(n) || n <= 0) {
104
+ throw new Error(`Invalid videoBitrateMbps: ${videoBitrateMbps}`);
105
+ }
106
+ args.push('--video-bitrate-mbps', String(n));
107
+ }
108
+
109
+ return args;
110
+ }
111
+
112
+ /**
113
+ * Format a human-readable command string (for dry-run / logs).
114
+ *
115
+ * @param {string[]} args
116
+ * @param {string} [binPath='gwr']
117
+ * @returns {string}
118
+ */
119
+ export function formatPilioCommand(args, binPath = 'gwr') {
120
+ const quote = (s) => (/\s/.test(s) ? JSON.stringify(s) : s);
121
+ return [quote(binPath), ...args.map(quote)].join(' ');
122
+ }
123
+
124
+ /**
125
+ * Run the pilio gwr CLI.
126
+ *
127
+ * @param {object} options
128
+ * @param {string} options.inputPath
129
+ * @param {string} [options.outputPath]
130
+ * @param {string} [options.outDir]
131
+ * @param {boolean} [options.overwrite=false]
132
+ * @param {boolean} [options.json=false]
133
+ * @param {number} [options.videoBitrateMbps]
134
+ * @param {boolean} [options.dryRun=false]
135
+ * @param {(chunk: string) => void} [options.onStderr]
136
+ * @returns {Promise<{ outputPath?: string, outDir?: string, dryRun?: boolean, command: string, exitCode?: number, stdout?: string }>}
137
+ */
138
+ export async function runPilioRemove(options = {}) {
139
+ const { dryRun = false, onStderr } = options;
140
+ const { binPath } = resolvePilioBin();
141
+ const args = buildPilioArgs(options);
142
+ const command = formatPilioCommand(args, binPath);
143
+
144
+ if (dryRun) {
145
+ return {
146
+ outputPath: options.outputPath,
147
+ outDir: options.outDir,
148
+ dryRun: true,
149
+ command,
150
+ };
151
+ }
152
+
153
+ if (options.outputPath) {
154
+ fs.mkdirSync(path.dirname(path.resolve(options.outputPath)), { recursive: true });
155
+ }
156
+ if (options.outDir) {
157
+ fs.mkdirSync(path.resolve(options.outDir), { recursive: true });
158
+ }
159
+
160
+ return new Promise((resolve, reject) => {
161
+ const child = spawn(process.execPath, [binPath, ...args], {
162
+ stdio: ['ignore', 'pipe', 'pipe'],
163
+ env: process.env,
164
+ });
165
+
166
+ let stdout = '';
167
+ let stderr = '';
168
+
169
+ child.stdout.on('data', (chunk) => {
170
+ stdout += chunk.toString();
171
+ });
172
+ child.stderr.on('data', (chunk) => {
173
+ const text = chunk.toString();
174
+ stderr += text;
175
+ if (onStderr) onStderr(text);
176
+ });
177
+
178
+ child.on('error', (err) => {
179
+ reject(new Error(
180
+ `Failed to execute watermark provider "${PILIO_PROVIDER_ID}" (${PILIO_PACKAGE}): ${err.message}`
181
+ ));
182
+ });
183
+
184
+ child.on('close', (code) => {
185
+ if (code !== 0) {
186
+ const detail = (stderr || stdout || '').trim() || `exit code ${code}`;
187
+ reject(new Error(
188
+ `Watermark provider "${PILIO_PROVIDER_ID}" failed for ${options.inputPath}: ${detail}`
189
+ ));
190
+ return;
191
+ }
192
+ resolve({
193
+ outputPath: options.outputPath,
194
+ outDir: options.outDir,
195
+ command,
196
+ exitCode: code,
197
+ stdout,
198
+ });
199
+ });
200
+ });
201
+ }
package/src/swap/steps.js CHANGED
@@ -8,10 +8,7 @@ import { uploadVideo } from '../uploader.js';
8
8
  */
9
9
 
10
10
  /**
11
- * Step 1 — Upload the new video as unlisted (never straight to public).
12
- *
13
- * Metadata (title/description/tags/category) is carried over from the old
14
- * video's snippet when available so the replacement stays continuous.
11
+ * Upload the new video for a playlist swap.
15
12
  *
16
13
  * @param {import('googleapis').youtube_v3.Youtube} youtube
17
14
  * @param {string} newVideoPath
@@ -19,6 +16,7 @@ import { uploadVideo } from '../uploader.js';
19
16
  * @param {string} [opts.oldVideoId] - Fetch old video's snippet to carry over
20
17
  * @param {object} [opts.meta] - Explicit metadata overrides
21
18
  * @param {number} [opts.retries=3]
19
+ * @param {'private' | 'unlisted' | 'public'} [opts.uploadPrivacy='unlisted']
22
20
  * @returns {Promise<{ videoId: string, url: string }>}
23
21
  */
24
22
  export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
@@ -26,12 +24,14 @@ export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
26
24
  throw new Error(`New video file not found: ${newVideoPath}`);
27
25
  }
28
26
 
27
+ const uploadPrivacy = opts.uploadPrivacy || opts.meta?.privacyStatus || 'unlisted';
28
+
29
29
  let meta = {
30
30
  title: opts.meta?.title,
31
31
  description: opts.meta?.description,
32
32
  tags: opts.meta?.tags,
33
33
  categoryId: opts.meta?.categoryId,
34
- privacyStatus: 'unlisted',
34
+ privacyStatus: uploadPrivacy,
35
35
  selfDeclaredMadeForKids: opts.meta?.selfDeclaredMadeForKids ?? false,
36
36
  };
37
37
 
@@ -49,7 +49,7 @@ export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
49
49
  description: meta.description ?? old.snippet?.description,
50
50
  tags: meta.tags || old.snippet?.tags || [],
51
51
  categoryId: meta.categoryId || old.snippet?.categoryId,
52
- privacyStatus: 'unlisted',
52
+ privacyStatus: uploadPrivacy,
53
53
  selfDeclaredMadeForKids: meta.selfDeclaredMadeForKids
54
54
  ?? (old.status?.madeForKids !== undefined ? old.status.madeForKids : false),
55
55
  };
@@ -68,7 +68,7 @@ export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
68
68
  let lastErr;
69
69
  for (let attempt = 1; attempt <= retries; attempt++) {
70
70
  try {
71
- return await uploadVideo(youtube, newVideoPath, meta, 'unlisted');
71
+ return await uploadVideo(youtube, newVideoPath, meta, uploadPrivacy);
72
72
  } catch (err) {
73
73
  lastErr = err;
74
74
  if (attempt < retries) {
@@ -142,20 +142,25 @@ export async function removeOldItem(youtube, oldPlaylistItemId) {
142
142
  }
143
143
 
144
144
  /**
145
- * Step 5 — Finalize: unlist the old video (never delete), then publish the
146
- * new one. Ordered so any earlier failure leaves the old video untouched.
145
+ * Step 5 — Finalize swap privacy on old and new videos.
147
146
  *
148
147
  * @param {import('googleapis').youtube_v3.Youtube} youtube
149
148
  * @param {string} oldVideoId
150
149
  * @param {string} newVideoId
150
+ * @param {object} [options]
151
+ * @param {'private' | 'unlisted' | 'public'} [options.newPrivacyStatus='public']
152
+ * @param {'private' | 'unlisted' | 'public'} [options.oldPrivacyStatus='unlisted']
151
153
  */
152
- export async function finalizeSwap(youtube, oldVideoId, newVideoId) {
154
+ export async function finalizeSwap(youtube, oldVideoId, newVideoId, options = {}) {
155
+ const newPrivacyStatus = options.newPrivacyStatus || 'public';
156
+ const oldPrivacyStatus = options.oldPrivacyStatus || 'unlisted';
157
+
153
158
  await youtube.videos.update({
154
159
  part: ['status'],
155
- requestBody: { id: oldVideoId, status: { privacyStatus: 'unlisted' } },
160
+ requestBody: { id: oldVideoId, status: { privacyStatus: oldPrivacyStatus } },
156
161
  });
157
162
  await youtube.videos.update({
158
163
  part: ['status'],
159
- requestBody: { id: newVideoId, status: { privacyStatus: 'public' } },
164
+ requestBody: { id: newVideoId, status: { privacyStatus: newPrivacyStatus } },
160
165
  });
161
166
  }
@@ -33,6 +33,7 @@ export const QUOTA_PER_SWAP = 150 + 1; // ~150 shared units + 1 upload-bucket un
33
33
  * @param {string} [params.stateDir] - Where the swap state file lives (default: cwd)
34
34
  * @param {string} [params.stateFile] - Custom state filename
35
35
  * @param {boolean} [params.fresh=false] - Reset any existing record for songKey
36
+ * @param {'preserve' | 'private' | 'public'} [params.privacyMode='public'] - Final privacy for new video; preserve reads old video status
36
37
  * @param {(event: { type: string, [key: string]: any }) => void} [params.onEvent]
37
38
  * @returns {Promise<{ record: any, dryRunPlan?: string[] }>}
38
39
  */
@@ -47,6 +48,7 @@ export async function swapSong(params) {
47
48
  stateDir,
48
49
  stateFile,
49
50
  fresh = false,
51
+ privacyMode = 'public',
50
52
  onEvent = () => {},
51
53
  } = params;
52
54
 
@@ -84,13 +86,46 @@ export async function swapSong(params) {
84
86
  }
85
87
 
86
88
  try {
89
+ let resolvedNewPrivacy = 'public';
90
+ let resolvedOldPrivacy = 'unlisted';
91
+ let uploadPrivacy = 'unlisted';
92
+
93
+ if (privacyMode === 'private') {
94
+ resolvedNewPrivacy = 'private';
95
+ resolvedOldPrivacy = 'unlisted';
96
+ uploadPrivacy = 'private';
97
+ } else if (privacyMode === 'preserve') {
98
+ try {
99
+ const res = await youtube.videos.list({
100
+ part: ['status'],
101
+ id: [oldVideoId],
102
+ });
103
+ const oldStatus = res.data.items?.[0]?.status?.privacyStatus;
104
+ if (oldStatus === 'private' || oldStatus === 'unlisted' || oldStatus === 'public') {
105
+ resolvedNewPrivacy = oldStatus;
106
+ uploadPrivacy = oldStatus;
107
+ } else {
108
+ resolvedNewPrivacy = 'private';
109
+ uploadPrivacy = 'private';
110
+ }
111
+ } catch {
112
+ resolvedNewPrivacy = 'private';
113
+ uploadPrivacy = 'private';
114
+ }
115
+ resolvedOldPrivacy = 'unlisted';
116
+ }
117
+
87
118
  // 1. UPLOAD
88
119
  await step('uploaded', async () => {
89
120
  const alreadyUploaded = store.getRecord(songKey).newVideoId;
90
121
  if (alreadyUploaded) {
91
122
  return { videoId: alreadyUploaded };
92
123
  }
93
- const up = await uploadNewVideo(youtube, newVideoPath, { oldVideoId, meta });
124
+ const up = await uploadNewVideo(youtube, newVideoPath, {
125
+ oldVideoId,
126
+ meta,
127
+ uploadPrivacy,
128
+ });
94
129
  onEvent({ type: 'swap_uploaded', songKey, newVideoId: up.videoId, url: up.url });
95
130
  return up;
96
131
  }, r => ({ newVideoId: r.videoId }));
@@ -128,7 +163,10 @@ export async function swapSong(params) {
128
163
 
129
164
  // 5. FINALIZE
130
165
  await step('finalized', async () => {
131
- await finalizeSwap(youtube, oldVideoId, newVideoId);
166
+ await finalizeSwap(youtube, oldVideoId, newVideoId, {
167
+ newPrivacyStatus: resolvedNewPrivacy,
168
+ oldPrivacyStatus: resolvedOldPrivacy,
169
+ });
132
170
  });
133
171
 
134
172
  onEvent({ type: 'swap_complete', songKey, oldVideoId, newVideoId, playlistId });
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