@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/README.md +59 -1
- package/bin/cli.js +344 -6
- package/package.json +5 -3
- package/src/access/privacy.js +113 -0
- package/src/access/set-access.js +247 -0
- package/src/actions/index.js +31 -0
- package/src/actions/remove-watermark.js +109 -0
- package/src/cli/remove-watermark.js +252 -0
- package/src/index.d.ts +147 -1
- package/src/index.js +42 -0
- package/src/playlist/detect-outdated.js +50 -0
- package/src/playlist/order.js +57 -0
- package/src/playlist/reorder.js +90 -0
- package/src/playlist/sync.js +192 -0
- package/src/providers/watermark/index.js +113 -0
- package/src/providers/watermark/pilio.js +201 -0
- package/src/swap/steps.js +17 -12
- package/src/swap/swapSong.js +40 -2
- package/src/update.js +165 -25
package/README.md
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
- **๐ก๏ธ Safe Quota Batching**: `--safe-quota`, `--limit`, and `--max-quota` defer excess uploads for the next day (~1,600 units/video + 50/playlist item).
|
|
15
15
|
- **๐ Quota Estimation**: Warns when estimated units approach the standard 10,000/day limit.
|
|
16
16
|
- **๐ Playlist Video Swap**: `swap` subcommand replaces a playlist entry in place; old video is unlisted, never deleted. Resumable via `.swap-history.json`.
|
|
17
|
+
- **๐ Sync Playlist**: `sync-playlist` detects local files newer than upload history, swaps them in-place (private by default), and realigns playlist order with overview prefix + album tracks.
|
|
18
|
+
- **๐ Set Access**: `set-access` sets privacy for a playlist and all its videos (`private`, `unlisted`, or `public`).
|
|
17
19
|
- **๐ท๏ธ Metadata & Title Updates**: `update-metadata` / `update-titles` subcommand to batch update video titles and playlist titles on YouTube and in `metadata.json` (e.g. adding postfixes, prefixes, or syncing).
|
|
18
20
|
- **๐ฌ Automated Stock Media Sourcing**: Search Pexels and Pixabay for high-res stock videos/photos, rank and auto-select best matches, cache responses, and compose final `.mp4` files from audio tracks via ffmpeg.
|
|
21
|
+
- **๐งน Gemini Watermark Removal**: Strip visible Gemini/Veo watermarks from local images and videos via MIT-licensed providers (`remove-watermark` command + `remove-watermark` pipeline action; default provider `@pilio/gemini-watermark-remover`).
|
|
19
22
|
- **๐ Dry Run Mode**: Validate metadata and estimate quota before uploading or updating.
|
|
20
23
|
|
|
21
24
|
---
|
|
@@ -237,11 +240,16 @@ Options:
|
|
|
237
240
|
--playlist-postfix <postfix> Postfix to append to playlist title (e.g. " (long versions)")
|
|
238
241
|
--title-prefix <prefix> Prefix to prepend to each video title
|
|
239
242
|
--playlist-title <title> Explicit new title for the playlist
|
|
243
|
+
--replace-pattern <pattern> Find/replace pattern in titles (and metadata.json when saving)
|
|
244
|
+
--replace-with <replacement> Replacement string for --replace-pattern
|
|
245
|
+
--replace-literal Treat --replace-pattern as a plain substring (not regex)
|
|
246
|
+
--replace-in-snippet Also replace in YouTube descriptions and tags
|
|
247
|
+
--include-playlist <id> Include all playlist videos (e.g. overview prefix videos)
|
|
240
248
|
--video <videoId> Target a specific YouTube video ID
|
|
241
249
|
--title <title> Explicit new title for the targeted video
|
|
242
250
|
--playlist <playlistId> Target a specific YouTube playlist ID
|
|
243
251
|
--no-update-file Do not update metadata.json on disk
|
|
244
|
-
|
|
252
|
+
--dry-run Preview changes without making any API calls
|
|
245
253
|
--token-file <file> OAuth token path (default: token.json in target dir)
|
|
246
254
|
--client-secrets-file <file> Path to client_secret.json
|
|
247
255
|
--client-id <id> Google OAuth Client ID
|
|
@@ -283,6 +291,23 @@ npx @x12i/youtube-video-uploader-cli ./my-videos
|
|
|
283
291
|
npx @x12i/youtube-video-uploader-cli ./my-videos --client-secrets-file ./client_secret.json
|
|
284
292
|
```
|
|
285
293
|
|
|
294
|
+
### 6. Rename band/artist text across playlist titles and metadata
|
|
295
|
+
```bash
|
|
296
|
+
yvu-cli update-metadata ./my-album \
|
|
297
|
+
--replace-pattern "The White Stoic" \
|
|
298
|
+
--replace-with "White Stoic" \
|
|
299
|
+
--replace-literal \
|
|
300
|
+
--include-playlist PLxxxx \
|
|
301
|
+
--dry-run
|
|
302
|
+
|
|
303
|
+
# Apply:
|
|
304
|
+
yvu-cli update-metadata ./my-album \
|
|
305
|
+
--replace-pattern "The White Stoic" \
|
|
306
|
+
--replace-with "White Stoic" \
|
|
307
|
+
--replace-literal \
|
|
308
|
+
--include-playlist PLxxxx
|
|
309
|
+
```
|
|
310
|
+
|
|
286
311
|
---
|
|
287
312
|
|
|
288
313
|
## ๐ Replacing a Video in a Playlist (`swap`)
|
|
@@ -481,6 +506,39 @@ yt-uploader preview-journey 01-first.mp4 --dir ./my-videos
|
|
|
481
506
|
yt-uploader preview-journey 01-first.mp4 --center-text "Custom prompt question?" -o ./preview.png
|
|
482
507
|
```
|
|
483
508
|
|
|
509
|
+
#### Remove Watermark (`remove-watermark`)
|
|
510
|
+
Clean visible Gemini/Veo watermarks from a local file or folder using a permissive-license provider (default: `pilio` / `@pilio/gemini-watermark-remover`):
|
|
511
|
+
|
|
512
|
+
```bash
|
|
513
|
+
# Single file โ writes stem-clean.ext next to the input
|
|
514
|
+
yt-uploader remove-watermark ./clip.mp4
|
|
515
|
+
|
|
516
|
+
# Explicit output path
|
|
517
|
+
yt-uploader remove-watermark ./clip.mp4 -o ./clean.mp4
|
|
518
|
+
|
|
519
|
+
# Batch a folder into a sibling *-clean directory
|
|
520
|
+
yt-uploader remove-watermark ./gemini-exports --out-dir ./gemini-exports-clean
|
|
521
|
+
|
|
522
|
+
# Preview provider commands
|
|
523
|
+
yt-uploader remove-watermark ./clip.mp4 --dry-run
|
|
524
|
+
|
|
525
|
+
# Higher video encode target (provider-specific)
|
|
526
|
+
yt-uploader remove-watermark ./clip.mp4 -o ./clean.mp4 --video-bitrate-mbps 20 --overwrite
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Or as a pipeline action in `metadata.json` / `--actions-file`:
|
|
530
|
+
|
|
531
|
+
```json
|
|
532
|
+
{
|
|
533
|
+
"type": "remove-watermark",
|
|
534
|
+
"provider": "pilio",
|
|
535
|
+
"videoBitrateMbps": 20,
|
|
536
|
+
"overwrite": true
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Only MIT / Apache-2.0 / BSD / ISC providers are registered. Additional providers are added under `src/providers/watermark/` without changing the pipeline.
|
|
541
|
+
|
|
484
542
|
---
|
|
485
543
|
|
|
486
544
|
## ๐ ๏ธ Programmatic Node.js API
|
package/bin/cli.js
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
swapSong,
|
|
12
12
|
SwapStore,
|
|
13
13
|
QUOTA_PER_SWAP,
|
|
14
|
+
syncPlaylist,
|
|
15
|
+
setPlaylistAccess,
|
|
14
16
|
batchUpdateMetadata,
|
|
15
17
|
searchMedia,
|
|
16
18
|
rankCandidates,
|
|
@@ -18,6 +20,9 @@ import {
|
|
|
18
20
|
composeVideo,
|
|
19
21
|
batchApplyActions,
|
|
20
22
|
previewJourney,
|
|
23
|
+
batchRemoveWatermark,
|
|
24
|
+
listWatermarkProviders,
|
|
25
|
+
DEFAULT_WATERMARK_PROVIDER,
|
|
21
26
|
} from '../src/index.js';
|
|
22
27
|
import { authenticate } from '../src/auth.js';
|
|
23
28
|
|
|
@@ -248,6 +253,7 @@ program
|
|
|
248
253
|
.option('--client-id <id>', 'Google OAuth Client ID')
|
|
249
254
|
.option('--client-secret <secret>', 'Google OAuth Client Secret')
|
|
250
255
|
.option('--fresh', 'Discard any previous swap state for this key and start over', false)
|
|
256
|
+
.option('--privacy <preserve|private|public>', 'Final privacy for the new video (default: public)', 'public')
|
|
251
257
|
.option('-n, --dry-run', 'Print the swap plan without any API calls', false)
|
|
252
258
|
.option('-q, --quiet', 'Minimal output mode', false)
|
|
253
259
|
.action(async (videoFile, options) => {
|
|
@@ -318,6 +324,7 @@ program
|
|
|
318
324
|
stateDir,
|
|
319
325
|
stateFile: options.stateFile,
|
|
320
326
|
fresh: options.fresh,
|
|
327
|
+
privacyMode: options.privacy || 'public',
|
|
321
328
|
onEvent: (event) => {
|
|
322
329
|
if (isQuiet) return;
|
|
323
330
|
if (event.type === 'swap_step') {
|
|
@@ -337,7 +344,8 @@ program
|
|
|
337
344
|
console.log();
|
|
338
345
|
console.log(pc.green(pc.bold(`๐ Swap complete!`)));
|
|
339
346
|
console.log(` โข Playlist entry replaced: ${pc.bold(options.playlist)} (position preserved)`);
|
|
340
|
-
console.log(` โข New video
|
|
347
|
+
console.log(` โข New video privacy : ${pc.bold(options.privacy || 'public')}`);
|
|
348
|
+
console.log(` โข New video : ${pc.underline(pc.blue(`https://youtu.be/${record.newVideoId}`))}`);
|
|
341
349
|
console.log(` โข Old video unlisted : ${pc.dim(`https://youtu.be/${options.oldVideo} (direct links still work)`)}`);
|
|
342
350
|
console.log();
|
|
343
351
|
}
|
|
@@ -347,6 +355,202 @@ program
|
|
|
347
355
|
}
|
|
348
356
|
});
|
|
349
357
|
|
|
358
|
+
program
|
|
359
|
+
.command('sync-playlist')
|
|
360
|
+
.description('Push local videos that changed since upload (playlist swap) and realign playlist order โ keeps videos private by default')
|
|
361
|
+
.argument('[directory]', 'Album folder with metadata.json and .upload-history.json (default: current directory)', '.')
|
|
362
|
+
.option('--album-dir <path>', 'Explicit album directory path (alias for positional directory)')
|
|
363
|
+
.option('-d, --dir <path>', 'Alias for --album-dir')
|
|
364
|
+
.requiredOption('--playlist <id>', 'YouTube playlist ID to sync')
|
|
365
|
+
.option('--overview-history <file>', 'Upload history JSON for overview/prefix videos')
|
|
366
|
+
.option('--overview-key <file>', 'Overview filename keys in order (repeatable)', (value, prev) => [...prev, value], [])
|
|
367
|
+
.option('--only <filename>', 'Restrict push to specific filenames (repeatable)', (value, prev) => [...prev, value], [])
|
|
368
|
+
.option('--privacy <preserve|private|public>', 'Privacy for swapped videos (default: private)', 'private')
|
|
369
|
+
.option('--skip-reorder', 'Push outdated files without reordering the playlist', false)
|
|
370
|
+
.option('--fresh', 'Discard previous swap state and start swaps fresh', false)
|
|
371
|
+
.option('--token-file <file>', 'OAuth token file (default: token.json in target dir)')
|
|
372
|
+
.option('--client-secrets-file <file>', 'Path to client_secret.json')
|
|
373
|
+
.option('--client-id <id>', 'Google OAuth Client ID')
|
|
374
|
+
.option('--client-secret <secret>', 'Google OAuth Client Secret')
|
|
375
|
+
.option('-n, --dry-run', 'Print the sync plan without API calls')
|
|
376
|
+
.option('-q, --quiet', 'Minimal output mode', false)
|
|
377
|
+
.action(async (directoryArg, options) => {
|
|
378
|
+
const isQuiet = options.quiet;
|
|
379
|
+
const albumDir = path.resolve(options.albumDir || options.dir || directoryArg || process.cwd());
|
|
380
|
+
const dryRun = Boolean(options.dryRun);
|
|
381
|
+
|
|
382
|
+
if (!isQuiet) {
|
|
383
|
+
console.log();
|
|
384
|
+
console.log(pc.bold(pc.red(`๐ Sync Playlist`)) + ` ${pc.dim(`v${pkg.version}`)}`);
|
|
385
|
+
console.log(pc.dim(`โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ`));
|
|
386
|
+
console.log(`${pc.bold('๐ Album Dir :')} ${pc.white(albumDir)}`);
|
|
387
|
+
console.log(`${pc.bold('๐ Playlist ID :')} ${pc.white(options.playlist)}`);
|
|
388
|
+
if (options.overviewHistory) {
|
|
389
|
+
console.log(`${pc.bold('๐ฌ Overview History:')} ${pc.white(path.resolve(options.overviewHistory))}`);
|
|
390
|
+
}
|
|
391
|
+
console.log(`${pc.bold('๐ Privacy Mode :')} ${pc.white(options.privacy || 'private')}`);
|
|
392
|
+
console.log();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const tokenFile = options.tokenFile
|
|
397
|
+
? path.resolve(options.tokenFile)
|
|
398
|
+
: path.join(albumDir, 'token.json');
|
|
399
|
+
|
|
400
|
+
let youtube = null;
|
|
401
|
+
if (!dryRun) {
|
|
402
|
+
const auth = await authenticate({
|
|
403
|
+
tokenFile,
|
|
404
|
+
clientSecretsFile: options.clientSecretsFile,
|
|
405
|
+
clientId: options.clientId,
|
|
406
|
+
clientSecret: options.clientSecret,
|
|
407
|
+
});
|
|
408
|
+
youtube = google.youtube({ version: 'v3', auth });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const result = await syncPlaylist({
|
|
412
|
+
youtube,
|
|
413
|
+
albumDir,
|
|
414
|
+
playlistId: options.playlist,
|
|
415
|
+
overviewHistoryPath: options.overviewHistory ? path.resolve(options.overviewHistory) : undefined,
|
|
416
|
+
overviewKeys: options.overviewKey,
|
|
417
|
+
onlyFilenames: options.only,
|
|
418
|
+
privacyMode: options.privacy || 'private',
|
|
419
|
+
dryRun,
|
|
420
|
+
skipReorder: options.skipReorder,
|
|
421
|
+
freshSwap: options.fresh,
|
|
422
|
+
onEvent: (event) => {
|
|
423
|
+
if (isQuiet) return;
|
|
424
|
+
if (event.type === 'sync_push_start') {
|
|
425
|
+
console.log(pc.cyan(`โฌ๏ธ Pushing ${event.filename} (replacing ${event.oldVideoId})โฆ`));
|
|
426
|
+
} else if (event.type === 'sync_push_success') {
|
|
427
|
+
console.log(pc.green(` โ
${event.filename} โ ${event.record.newVideoId}`));
|
|
428
|
+
} else if (event.type === 'sync_reorder_success') {
|
|
429
|
+
console.log(pc.green(` ๐ Reordered playlist (${event.changed} position updates)`));
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
if (!isQuiet) {
|
|
435
|
+
if (dryRun || result.dryRun) {
|
|
436
|
+
console.log(pc.yellow('๐ DRY RUN plan'));
|
|
437
|
+
console.log(` Outdated local files: ${result.outdated.length}`);
|
|
438
|
+
for (const item of result.outdated) {
|
|
439
|
+
console.log(` โข ${item.filename} (local ${item.modifiedAt} > uploaded ${item.uploadedAt})`);
|
|
440
|
+
}
|
|
441
|
+
console.log(` Desired order (${result.desiredOrder.length} videos):`);
|
|
442
|
+
result.desiredOrder.forEach((id, i) => console.log(` ${i}. ${id}`));
|
|
443
|
+
if (result.currentOrder?.length) {
|
|
444
|
+
console.log(` Current order aligned: ${result.orderAligned ? 'yes' : 'no'}`);
|
|
445
|
+
}
|
|
446
|
+
console.log();
|
|
447
|
+
} else {
|
|
448
|
+
console.log();
|
|
449
|
+
console.log(pc.green(pc.bold('๐ Sync complete')));
|
|
450
|
+
console.log(` โข Pushed : ${result.pushed.length} video(s)`);
|
|
451
|
+
console.log(` โข Reordered: ${result.reordered ? 'yes' : 'already aligned'}`);
|
|
452
|
+
console.log(` โข Playlist : https://www.youtube.com/playlist?list=${options.playlist}`);
|
|
453
|
+
console.log();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} catch (err) {
|
|
457
|
+
console.error(pc.red(`\nโ Error: ${err.message}\n`));
|
|
458
|
+
process.exit(1);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
program
|
|
463
|
+
.command('set-access')
|
|
464
|
+
.description('Set privacy/access for a playlist and its videos (private, unlisted, or public)')
|
|
465
|
+
.argument('[directory]', 'Album folder for metadata/history updates (default: current directory)', '.')
|
|
466
|
+
.option('--album-dir <path>', 'Explicit album directory path')
|
|
467
|
+
.requiredOption('--playlist <id>', 'YouTube playlist ID')
|
|
468
|
+
.requiredOption('--privacy <private|unlisted|public>', 'Target privacy status')
|
|
469
|
+
.option('--videos <playlist|history>', 'Where to resolve member videos (default: playlist)', 'playlist')
|
|
470
|
+
.option('--overview-history <file>', 'Overview upload history when --videos history')
|
|
471
|
+
.option('--overview-key <file>', 'Overview filename keys in order (repeatable)', (value, prev) => [...prev, value], [])
|
|
472
|
+
.option('--update-metadata', 'Write privacy status into metadata.json', false)
|
|
473
|
+
.option('--token-file <file>', 'OAuth token file (default: token.json in album dir)')
|
|
474
|
+
.option('--client-secrets-file <file>', 'Path to client_secret.json')
|
|
475
|
+
.option('--client-id <id>', 'Google OAuth Client ID')
|
|
476
|
+
.option('--client-secret <secret>', 'Google OAuth Client Secret')
|
|
477
|
+
.option('--dry-run', 'Print the access plan without API updates')
|
|
478
|
+
.option('-q, --quiet', 'Minimal output mode', false)
|
|
479
|
+
.action(async (directoryArg, options) => {
|
|
480
|
+
const isQuiet = options.quiet;
|
|
481
|
+
const albumDir = path.resolve(options.albumDir || directoryArg || process.cwd());
|
|
482
|
+
const parentOpts = program.opts();
|
|
483
|
+
const dryRun = Boolean(options.dryRun || parentOpts.dryRun);
|
|
484
|
+
|
|
485
|
+
if (!isQuiet) {
|
|
486
|
+
console.log();
|
|
487
|
+
console.log(pc.bold(pc.red(`๐ Set Access`)) + ` ${pc.dim(`v${pkg.version}`)}`);
|
|
488
|
+
console.log(pc.dim(`โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ`));
|
|
489
|
+
console.log(`${pc.bold('๐ Album Dir :')} ${pc.white(albumDir)}`);
|
|
490
|
+
console.log(`${pc.bold('๐ Playlist ID :')} ${pc.white(options.playlist)}`);
|
|
491
|
+
console.log(`${pc.bold('๐ Target Privacy :')} ${pc.white(options.privacy)}`);
|
|
492
|
+
console.log(`${pc.bold('๐ฌ Video Source :')} ${pc.white(options.videos || 'playlist')}`);
|
|
493
|
+
console.log();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
const tokenFile = options.tokenFile
|
|
498
|
+
? path.resolve(options.tokenFile)
|
|
499
|
+
: path.join(albumDir, 'token.json');
|
|
500
|
+
|
|
501
|
+
let youtube = null;
|
|
502
|
+
const auth = await authenticate({
|
|
503
|
+
tokenFile,
|
|
504
|
+
clientSecretsFile: options.clientSecretsFile,
|
|
505
|
+
clientId: options.clientId,
|
|
506
|
+
clientSecret: options.clientSecret,
|
|
507
|
+
});
|
|
508
|
+
youtube = google.youtube({ version: 'v3', auth });
|
|
509
|
+
|
|
510
|
+
const result = await setPlaylistAccess({
|
|
511
|
+
youtube,
|
|
512
|
+
playlistId: options.playlist,
|
|
513
|
+
privacyStatus: options.privacy,
|
|
514
|
+
videoSource: options.videos || 'playlist',
|
|
515
|
+
albumDir,
|
|
516
|
+
overviewHistoryPath: options.overviewHistory ? path.resolve(options.overviewHistory) : undefined,
|
|
517
|
+
overviewKeys: options.overviewKey,
|
|
518
|
+
updateMetadata: Boolean(options.updateMetadata),
|
|
519
|
+
dryRun,
|
|
520
|
+
onEvent: (event) => {
|
|
521
|
+
if (isQuiet) return;
|
|
522
|
+
if (event.type === 'access_video_start') {
|
|
523
|
+
console.log(pc.cyan(` ๐ ${event.title || event.videoId} โ ${options.privacy}`));
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
if (!isQuiet) {
|
|
529
|
+
if (dryRun || result.dryRun) {
|
|
530
|
+
console.log(pc.yellow('๐ DRY RUN plan'));
|
|
531
|
+
console.log(` Playlist ${result.playlist.playlistId}: ${result.playlist.currentPrivacy || '?'} โ ${result.playlist.targetPrivacy}${result.playlist.changed ? '' : ' (no change)'}`);
|
|
532
|
+
console.log(` Videos (${result.videos.length}):`);
|
|
533
|
+
for (const video of result.videos) {
|
|
534
|
+
const label = video.title || video.videoId;
|
|
535
|
+
console.log(` โข ${label}: ${video.currentPrivacy || '?'} โ ${video.targetPrivacy}${video.changed ? '' : ' (no change)'}`);
|
|
536
|
+
}
|
|
537
|
+
console.log(` Estimated quota: ~${result.estimatedQuota} units`);
|
|
538
|
+
console.log();
|
|
539
|
+
} else {
|
|
540
|
+
console.log();
|
|
541
|
+
console.log(pc.green(pc.bold('๐ Access updated')));
|
|
542
|
+
console.log(` โข Playlist : ${result.updated.playlist ? options.privacy : 'unchanged'} (${options.playlist})`);
|
|
543
|
+
console.log(` โข Videos : ${result.updated.videos.length} updated to ${options.privacy}`);
|
|
544
|
+
console.log(` โข URL : https://www.youtube.com/playlist?list=${options.playlist}`);
|
|
545
|
+
console.log();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
} catch (err) {
|
|
549
|
+
console.error(pc.red(`\nโ Error: ${err.message}\n`));
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
|
|
350
554
|
program
|
|
351
555
|
.command('update-metadata')
|
|
352
556
|
.alias('update-titles')
|
|
@@ -360,12 +564,15 @@ program
|
|
|
360
564
|
.option('--playlist-title <title>', 'Explicit new title for the playlist')
|
|
361
565
|
.option('--replace-pattern <pattern>', 'Regex/string pattern to replace in titles')
|
|
362
566
|
.option('--replace-with <replacement>', 'Replacement string for --replace-pattern', '')
|
|
567
|
+
.option('--replace-literal', 'Treat --replace-pattern as a plain substring (not regex)', false)
|
|
568
|
+
.option('--replace-in-snippet', 'Also replace in descriptions and tags on YouTube', false)
|
|
569
|
+
.option('--include-playlist <id>', 'Include all videos in this playlist (covers overview/prefix videos outside metadata.json)')
|
|
363
570
|
.option('--sync-metadata', 'Sync titles directly from metadata.json to YouTube')
|
|
364
571
|
.option('--video <videoId>', 'Target a specific YouTube video ID')
|
|
365
572
|
.option('--title <title>', 'Explicit new title for the targeted video')
|
|
366
573
|
.option('--playlist <playlistId>', 'Target a specific YouTube playlist ID')
|
|
367
574
|
.option('--no-update-file', 'Do not update metadata.json on disk')
|
|
368
|
-
.option('
|
|
575
|
+
.option('--dry-run', 'Preview changes without making any API calls')
|
|
369
576
|
.option('--token-file <file>', 'Path to store or read OAuth token (default: token.json in target dir)')
|
|
370
577
|
.option('--client-secrets-file <file>', 'Path to client_secret.json downloaded from Google Cloud')
|
|
371
578
|
.option('--client-id <id>', 'Google OAuth Client ID')
|
|
@@ -374,12 +581,17 @@ program
|
|
|
374
581
|
.action(async (directoryArg, options) => {
|
|
375
582
|
const targetDir = path.resolve(options.dir || directoryArg || process.cwd());
|
|
376
583
|
const isQuiet = options.quiet;
|
|
584
|
+
const parentOpts = program.opts();
|
|
585
|
+
const dryRun = Boolean(options.dryRun || parentOpts.dryRun);
|
|
377
586
|
|
|
378
587
|
if (!isQuiet) {
|
|
379
588
|
console.log();
|
|
380
589
|
console.log(pc.bold(pc.cyan(`๐ YouTube Metadata & Title Updater`)) + ` ${pc.dim(`v${pkg.version}`)}`);
|
|
381
590
|
console.log(pc.dim(`โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ`));
|
|
382
591
|
console.log(`${pc.bold('๐ Target Folder :')} ${pc.white(targetDir)}`);
|
|
592
|
+
if (options.replacePattern) {
|
|
593
|
+
console.log(`${pc.bold('๐ Replace :')} ${pc.green(JSON.stringify(options.replacePattern))} โ ${pc.green(JSON.stringify(options.replaceWith || ''))}`);
|
|
594
|
+
}
|
|
383
595
|
if (options.titlePostfix) {
|
|
384
596
|
console.log(`${pc.bold('๐ท๏ธ Title Postfix :')} ${pc.green(JSON.stringify(options.titlePostfix))}`);
|
|
385
597
|
}
|
|
@@ -389,7 +601,10 @@ program
|
|
|
389
601
|
if (options.titlePrefix) {
|
|
390
602
|
console.log(`${pc.bold('๐ท๏ธ Title Prefix :')} ${pc.green(JSON.stringify(options.titlePrefix))}`);
|
|
391
603
|
}
|
|
392
|
-
if (options.
|
|
604
|
+
if (options.includePlaylist) {
|
|
605
|
+
console.log(`${pc.bold('๐ Include Playlist:')} ${pc.white(options.includePlaylist)}`);
|
|
606
|
+
}
|
|
607
|
+
if (dryRun) {
|
|
393
608
|
console.log(`${pc.yellow(pc.bold('๐ Mode :'))} ${pc.yellow('DRY RUN (no updates will be performed)')}`);
|
|
394
609
|
}
|
|
395
610
|
console.log();
|
|
@@ -407,13 +622,16 @@ program
|
|
|
407
622
|
playlistTitle: options.playlistTitle,
|
|
408
623
|
replacePattern: options.replacePattern,
|
|
409
624
|
replaceWith: options.replaceWith,
|
|
625
|
+
replaceLiteral: options.replaceLiteral,
|
|
626
|
+
replaceInSnippet: options.replaceInSnippet,
|
|
627
|
+
includePlaylistId: options.includePlaylist,
|
|
410
628
|
syncMetadata: options.syncMetadata,
|
|
411
629
|
metadataFile: options.metadata,
|
|
412
630
|
updateLocalMetadata: options.updateFile !== false,
|
|
413
631
|
singleVideoId: options.video,
|
|
414
632
|
singleVideoTitle: options.title,
|
|
415
633
|
singlePlaylistId: options.playlist,
|
|
416
|
-
dryRun
|
|
634
|
+
dryRun,
|
|
417
635
|
tokenFile,
|
|
418
636
|
clientSecretsFile: options.clientSecretsFile,
|
|
419
637
|
clientId: options.clientId,
|
|
@@ -465,7 +683,7 @@ program
|
|
|
465
683
|
});
|
|
466
684
|
|
|
467
685
|
if (!isQuiet) {
|
|
468
|
-
if (
|
|
686
|
+
if (dryRun) {
|
|
469
687
|
console.log(pc.yellow(`โจ Dry run complete. No YouTube updates were performed.`));
|
|
470
688
|
console.log();
|
|
471
689
|
process.exit(0);
|
|
@@ -733,7 +951,7 @@ program
|
|
|
733
951
|
|
|
734
952
|
program
|
|
735
953
|
.command('apply-actions')
|
|
736
|
-
.description('Run configured video processing actions (shorten, replace-background, journey-overlay) on videos')
|
|
954
|
+
.description('Run configured video processing actions (shorten, replace-background, journey-overlay, remove-watermark) on videos')
|
|
737
955
|
.argument('[directory]', 'Source folder containing metadata.json and video files (default: current directory)', '.')
|
|
738
956
|
.option('-d, --dir <path>', 'Explicit target directory path')
|
|
739
957
|
.option('-m, --metadata <file>', 'Custom path to metadata.json file')
|
|
@@ -866,6 +1084,126 @@ program
|
|
|
866
1084
|
}
|
|
867
1085
|
});
|
|
868
1086
|
|
|
1087
|
+
program
|
|
1088
|
+
.command('remove-watermark')
|
|
1089
|
+
.description('Remove Gemini/Veo visible watermarks from a local image/video file or folder (MIT providers)')
|
|
1090
|
+
.argument('[input]', 'Input file or folder (default: current directory)', '.')
|
|
1091
|
+
.option('-o, --output <file>', 'Output file path (single-file input)')
|
|
1092
|
+
.option('--out-dir <dir>', 'Output directory (folder input, or folder for a single file basename)')
|
|
1093
|
+
.option('--provider <id>', `Watermark provider id (default: ${DEFAULT_WATERMARK_PROVIDER})`, DEFAULT_WATERMARK_PROVIDER)
|
|
1094
|
+
.option('--overwrite', 'Overwrite existing outputs', false)
|
|
1095
|
+
.option('-f, --force', 'Alias for --overwrite', false)
|
|
1096
|
+
.option('--video-bitrate-mbps <n>', 'Video encode bitrate in Mbps (provider-specific)')
|
|
1097
|
+
.option('--json', 'Ask the provider for JSON logs when supported', false)
|
|
1098
|
+
.option('-n, --dry-run', 'Print planned provider commands without writing files', false)
|
|
1099
|
+
.option('-q, --quiet', 'Minimal output mode', false)
|
|
1100
|
+
.action(async (inputArg, options, command) => {
|
|
1101
|
+
const parentOpts = command?.parent?.opts() || {};
|
|
1102
|
+
const dryRun = Boolean(options.dryRun || parentOpts.dryRun);
|
|
1103
|
+
const overwrite = Boolean(options.overwrite || options.force || parentOpts.force);
|
|
1104
|
+
const isQuiet = Boolean(options.quiet || parentOpts.quiet);
|
|
1105
|
+
const input = path.resolve(inputArg || '.');
|
|
1106
|
+
|
|
1107
|
+
let videoBitrateMbps;
|
|
1108
|
+
if (options.videoBitrateMbps !== undefined) {
|
|
1109
|
+
videoBitrateMbps = parseFloat(options.videoBitrateMbps);
|
|
1110
|
+
if (!Number.isFinite(videoBitrateMbps) || videoBitrateMbps <= 0) {
|
|
1111
|
+
console.error(pc.red(`\nโ Error: Invalid --video-bitrate-mbps value\n`));
|
|
1112
|
+
process.exit(1);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (!isQuiet) {
|
|
1117
|
+
const providers = listWatermarkProviders()
|
|
1118
|
+
.map((p) => `${p.id} (${p.license})`)
|
|
1119
|
+
.join(', ');
|
|
1120
|
+
console.log();
|
|
1121
|
+
console.log(pc.bold(pc.cyan(`๐งน Remove Watermark`)) + ` ${pc.dim(`v${pkg.version}`)}`);
|
|
1122
|
+
console.log(pc.dim(`โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ`));
|
|
1123
|
+
console.log(`${pc.bold('๐ Input :')} ${pc.white(input)}`);
|
|
1124
|
+
console.log(`${pc.bold('๐ Provider :')} ${pc.cyan(options.provider)}`);
|
|
1125
|
+
console.log(`${pc.bold('๐ Known providers :')} ${pc.dim(providers)}`);
|
|
1126
|
+
if (options.output) {
|
|
1127
|
+
console.log(`${pc.bold('๐ Output File :')} ${pc.white(path.resolve(options.output))}`);
|
|
1128
|
+
}
|
|
1129
|
+
if (options.outDir) {
|
|
1130
|
+
console.log(`${pc.bold('๐ Output Dir :')} ${pc.white(path.resolve(options.outDir))}`);
|
|
1131
|
+
}
|
|
1132
|
+
if (videoBitrateMbps) {
|
|
1133
|
+
console.log(`${pc.bold('๐ฅ Video bitrate :')} ${pc.cyan(`${videoBitrateMbps} Mbps`)}`);
|
|
1134
|
+
}
|
|
1135
|
+
if (dryRun) {
|
|
1136
|
+
console.log(`${pc.yellow(pc.bold('๐ Mode :'))} ${pc.yellow('DRY RUN (no file writes)')}`);
|
|
1137
|
+
}
|
|
1138
|
+
if (overwrite) {
|
|
1139
|
+
console.log(`${pc.magenta(pc.bold('โก Overwrite :'))} ${pc.magenta('ENABLED')}`);
|
|
1140
|
+
}
|
|
1141
|
+
console.log();
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
try {
|
|
1145
|
+
const result = await batchRemoveWatermark(input, {
|
|
1146
|
+
output: options.output,
|
|
1147
|
+
outDir: options.outDir,
|
|
1148
|
+
provider: options.provider,
|
|
1149
|
+
overwrite,
|
|
1150
|
+
json: Boolean(options.json),
|
|
1151
|
+
videoBitrateMbps,
|
|
1152
|
+
dryRun,
|
|
1153
|
+
onEvent: (event) => {
|
|
1154
|
+
if (isQuiet) return;
|
|
1155
|
+
if (event.type === 'watermark_plan') {
|
|
1156
|
+
console.log(
|
|
1157
|
+
pc.bold('Found ') +
|
|
1158
|
+
pc.green(pc.bold(`${event.total}`)) +
|
|
1159
|
+
pc.bold(` media file(s) to clean.`)
|
|
1160
|
+
);
|
|
1161
|
+
if (event.outDir) {
|
|
1162
|
+
console.log(`${pc.bold('๐ Output Directory:')} ${pc.cyan(event.outDir)}`);
|
|
1163
|
+
}
|
|
1164
|
+
console.log();
|
|
1165
|
+
} else if (event.type === 'entry_start') {
|
|
1166
|
+
console.log(` ๐งน [${event.index + 1}/${event.total}] ${pc.bold(event.filename)}`);
|
|
1167
|
+
} else if (event.type === 'entry_complete') {
|
|
1168
|
+
if (dryRun) {
|
|
1169
|
+
console.log(` ${pc.dim(event.command)}`);
|
|
1170
|
+
} else {
|
|
1171
|
+
console.log(` ${pc.green('โ')} ${pc.underline(event.outputPath)}`);
|
|
1172
|
+
}
|
|
1173
|
+
} else if (event.type === 'entry_skipped') {
|
|
1174
|
+
console.log(` ${pc.yellow('โญ')} skipped: ${event.reason}`);
|
|
1175
|
+
} else if (event.type === 'entry_failed') {
|
|
1176
|
+
console.log(` ${pc.red('โ')} ${event.error?.message || event.error}`);
|
|
1177
|
+
}
|
|
1178
|
+
},
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
if (!isQuiet) {
|
|
1182
|
+
console.log();
|
|
1183
|
+
console.log(pc.bold('Summary'));
|
|
1184
|
+
console.log(` โข Processed : ${pc.green(String(result.processed.length))}`);
|
|
1185
|
+
console.log(` โข Skipped : ${pc.yellow(String(result.skipped.length))}`);
|
|
1186
|
+
console.log(` โข Failed : ${pc.red(String(result.failed.length))}`);
|
|
1187
|
+
console.log();
|
|
1188
|
+
if (dryRun) {
|
|
1189
|
+
console.log(pc.yellow(`โจ Dry run complete. No files were written.`));
|
|
1190
|
+
console.log();
|
|
1191
|
+
} else if (result.failed.length > 0) {
|
|
1192
|
+
console.log(pc.red(`โ ๏ธ Completed with ${result.failed.length} failure(s).`));
|
|
1193
|
+
process.exit(1);
|
|
1194
|
+
} else {
|
|
1195
|
+
console.log(pc.green(pc.bold(`๐ Watermark removal complete!`)));
|
|
1196
|
+
console.log();
|
|
1197
|
+
}
|
|
1198
|
+
} else if (result.failed.length > 0) {
|
|
1199
|
+
process.exit(1);
|
|
1200
|
+
}
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
console.error(pc.red(`\nโ Error: ${err.message}\n`));
|
|
1203
|
+
process.exit(1);
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
|
|
869
1207
|
program
|
|
870
1208
|
.command('preview-journey')
|
|
871
1209
|
.description('Generate and preview the journey overlay graphic as a standalone PNG image')
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x12i/youtube-video-uploader-cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "CLI tool to batch upload MP4 videos to YouTube via YouTube Data API v3 with OAuth2, metadata.json config, playlist assignment, and
|
|
3
|
+
"version": "1.6.1",
|
|
4
|
+
"description": "CLI tool to batch upload MP4 videos to YouTube via YouTube Data API v3 with OAuth2, metadata.json config, playlist assignment, swap, and sync-playlist alignment.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"author": "",
|
|
45
45
|
"license": "MIT",
|
|
46
46
|
"dependencies": {
|
|
47
|
+
"@pilio/gemini-watermark-remover": "^1.0.41",
|
|
47
48
|
"@resvg/resvg-js": "^2.6.2",
|
|
48
49
|
"commander": "^13.1.0",
|
|
49
50
|
"ffmpeg-static": "^5.2.0",
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
"mime-types": "^2.1.35",
|
|
54
55
|
"open": "^10.1.0",
|
|
55
56
|
"p-limit": "^6.1.0",
|
|
56
|
-
"picocolors": "^1.1.1"
|
|
57
|
+
"picocolors": "^1.1.1",
|
|
58
|
+
"sharp": "^0.35.4"
|
|
57
59
|
}
|
|
58
60
|
}
|