@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/bin/cli.js CHANGED
@@ -5,13 +5,44 @@ import path from 'node:path';
5
5
  import fs from 'node:fs';
6
6
  import pc from 'picocolors';
7
7
  import { google } from 'googleapis';
8
- import { batchUpload, DAILY_DEFAULT_QUOTA, swapSong, SwapStore, QUOTA_PER_SWAP } from '../src/index.js';
8
+ import {
9
+ batchUpload,
10
+ DAILY_DEFAULT_QUOTA,
11
+ swapSong,
12
+ SwapStore,
13
+ QUOTA_PER_SWAP,
14
+ batchUpdateMetadata,
15
+ searchMedia,
16
+ rankCandidates,
17
+ resolveMediaForMetadata,
18
+ composeVideo,
19
+ batchApplyActions,
20
+ previewJourney,
21
+ } from '../src/index.js';
9
22
  import { authenticate } from '../src/auth.js';
10
23
 
11
24
  // Read package.json for version
12
25
  const pkgPath = new URL('../package.json', import.meta.url);
13
26
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
14
27
 
28
+ // Auto-load .env if available
29
+ try {
30
+ process.loadEnvFile?.();
31
+ } catch {}
32
+ try {
33
+ let searchDir = process.cwd();
34
+ for (let i = 0; i < 4; i++) {
35
+ const candidate = path.join(searchDir, '.env');
36
+ if (fs.existsSync(candidate)) {
37
+ process.loadEnvFile?.(candidate);
38
+ break;
39
+ }
40
+ const parent = path.dirname(searchDir);
41
+ if (parent === searchDir) break;
42
+ searchDir = parent;
43
+ }
44
+ } catch {}
45
+
15
46
  const program = new Command();
16
47
 
17
48
  program
@@ -26,6 +57,10 @@ program
26
57
  .option('-l, --limit <number>', 'Maximum number of videos to upload in this run (e.g. 5 or 6)')
27
58
  .option('--safe-quota', 'Cap upload batch to stay safely within standard daily 10,000 units quota (default ~5-6 videos)', false)
28
59
  .option('--max-quota <units>', 'Maximum allowed API quota units for this run')
60
+ .option('--auto-media', 'Resolve and compose stock media for missing videos before uploading', false)
61
+ .option('--media-provider <provider>', 'Stock media provider for --auto-media: pexels, pixabay, auto (default: auto)')
62
+ .option('--pexels-key <key>', 'Pexels API key')
63
+ .option('--pixabay-key <key>', 'Pixabay API key')
29
64
  .option('--client-id <id>', 'Google OAuth Client ID')
30
65
  .option('--client-secret <secret>', 'Google OAuth Client Secret')
31
66
  .option('--client-secrets-file <file>', 'Path to client_secret.json downloaded from Google Cloud')
@@ -70,6 +105,10 @@ program
70
105
  limit: options.limit ? parseInt(options.limit, 10) : undefined,
71
106
  safeQuota: options.safeQuota,
72
107
  maxQuota: options.maxQuota ? parseInt(options.maxQuota, 10) : undefined,
108
+ autoMedia: options.autoMedia,
109
+ mediaProvider: options.mediaProvider,
110
+ pexelsKey: options.pexelsKey,
111
+ pixabayKey: options.pixabayKey,
73
112
  tokenFile,
74
113
  clientSecretsFile: options.clientSecretsFile,
75
114
  clientId: options.clientId,
@@ -77,7 +116,17 @@ program
77
116
  onEvent: (event) => {
78
117
  if (isQuiet) return;
79
118
 
80
- if (event.type === 'plan') {
119
+ if (event.type === 'auto_media_start') {
120
+ console.log(pc.cyan(`šŸŽ¬ Sourcing & resolving stock media (--auto-media)...`));
121
+ } else if (event.type === 'search_start') {
122
+ console.log(` šŸ” Searching ${event.provider} for "${event.query}" (${event.songKey})...`);
123
+ } else if (event.type === 'compose_start') {
124
+ console.log(` šŸŽžļø Composing video for ${pc.bold(event.songKey)} via ffmpeg...`);
125
+ } else if (event.type === 'compose_success') {
126
+ console.log(pc.green(` āœ… Composed:`), pc.dim(event.outputPath));
127
+ } else if (event.type === 'auto_media_complete') {
128
+ console.log(pc.green(`✨ Stock media resolution complete.\n`));
129
+ } else if (event.type === 'plan') {
81
130
  console.log(
82
131
  pc.bold(`Found `) +
83
132
  pc.green(pc.bold(`${event.total}`)) +
@@ -298,4 +347,573 @@ program
298
347
  }
299
348
  });
300
349
 
350
+ program
351
+ .command('update-metadata')
352
+ .alias('update-titles')
353
+ .description('Update titles and snippet metadata for videos and playlists on YouTube and in metadata.json')
354
+ .argument('[directory]', 'Target folder containing metadata.json and upload history (default: current directory)', '.')
355
+ .option('-d, --dir <path>', 'Explicit target directory path')
356
+ .option('-m, --metadata <file>', 'Custom path to metadata.json file')
357
+ .option('--title-postfix <postfix>', 'Postfix to append to each video title (e.g. " (long version)")')
358
+ .option('--playlist-postfix <postfix>', 'Postfix to append to playlist title (e.g. " (long versions)")')
359
+ .option('--title-prefix <prefix>', 'Prefix to prepend to each video title')
360
+ .option('--playlist-title <title>', 'Explicit new title for the playlist')
361
+ .option('--replace-pattern <pattern>', 'Regex/string pattern to replace in titles')
362
+ .option('--replace-with <replacement>', 'Replacement string for --replace-pattern', '')
363
+ .option('--sync-metadata', 'Sync titles directly from metadata.json to YouTube')
364
+ .option('--video <videoId>', 'Target a specific YouTube video ID')
365
+ .option('--title <title>', 'Explicit new title for the targeted video')
366
+ .option('--playlist <playlistId>', 'Target a specific YouTube playlist ID')
367
+ .option('--no-update-file', 'Do not update metadata.json on disk')
368
+ .option('-n, --dry-run', 'Preview changes without making any API calls')
369
+ .option('--token-file <file>', 'Path to store or read OAuth token (default: token.json in target dir)')
370
+ .option('--client-secrets-file <file>', 'Path to client_secret.json downloaded from Google Cloud')
371
+ .option('--client-id <id>', 'Google OAuth Client ID')
372
+ .option('--client-secret <secret>', 'Google OAuth Client Secret')
373
+ .option('-q, --quiet', 'Minimal output mode', false)
374
+ .action(async (directoryArg, options) => {
375
+ const targetDir = path.resolve(options.dir || directoryArg || process.cwd());
376
+ const isQuiet = options.quiet;
377
+
378
+ if (!isQuiet) {
379
+ console.log();
380
+ console.log(pc.bold(pc.cyan(`šŸ“ YouTube Metadata & Title Updater`)) + ` ${pc.dim(`v${pkg.version}`)}`);
381
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
382
+ console.log(`${pc.bold('šŸ“‚ Target Folder :')} ${pc.white(targetDir)}`);
383
+ if (options.titlePostfix) {
384
+ console.log(`${pc.bold('šŸ·ļø Title Postfix :')} ${pc.green(JSON.stringify(options.titlePostfix))}`);
385
+ }
386
+ if (options.playlistPostfix) {
387
+ console.log(`${pc.bold('šŸ“‘ Playlist Postfix:')} ${pc.green(JSON.stringify(options.playlistPostfix))}`);
388
+ }
389
+ if (options.titlePrefix) {
390
+ console.log(`${pc.bold('šŸ·ļø Title Prefix :')} ${pc.green(JSON.stringify(options.titlePrefix))}`);
391
+ }
392
+ if (options.dryRun) {
393
+ console.log(`${pc.yellow(pc.bold('šŸ” Mode :'))} ${pc.yellow('DRY RUN (no updates will be performed)')}`);
394
+ }
395
+ console.log();
396
+ }
397
+
398
+ try {
399
+ const tokenFile = options.tokenFile
400
+ ? path.resolve(options.tokenFile)
401
+ : path.join(targetDir, 'token.json');
402
+
403
+ const result = await batchUpdateMetadata(targetDir, {
404
+ titlePostfix: options.titlePostfix,
405
+ titlePrefix: options.titlePrefix,
406
+ playlistPostfix: options.playlistPostfix,
407
+ playlistTitle: options.playlistTitle,
408
+ replacePattern: options.replacePattern,
409
+ replaceWith: options.replaceWith,
410
+ syncMetadata: options.syncMetadata,
411
+ metadataFile: options.metadata,
412
+ updateLocalMetadata: options.updateFile !== false,
413
+ singleVideoId: options.video,
414
+ singleVideoTitle: options.title,
415
+ singlePlaylistId: options.playlist,
416
+ dryRun: options.dryRun,
417
+ tokenFile,
418
+ clientSecretsFile: options.clientSecretsFile,
419
+ clientId: options.clientId,
420
+ clientSecret: options.clientSecret,
421
+ onEvent: (event) => {
422
+ if (isQuiet) return;
423
+
424
+ if (event.type === 'update_plan') {
425
+ if (event.playlist) {
426
+ console.log(pc.bold('šŸ“‘ Playlist: ') + pc.dim(event.playlist.playlistId));
427
+ if (event.playlist.changed) {
428
+ console.log(` ${pc.dim('Old:')} ${event.playlist.oldTitle}`);
429
+ console.log(` ${pc.green('New:')} ${pc.bold(event.playlist.newTitle)}`);
430
+ } else {
431
+ console.log(` ${pc.dim('Unchanged:')} ${event.playlist.oldTitle}`);
432
+ }
433
+ console.log();
434
+ }
435
+
436
+ console.log(pc.bold(`šŸŽ¬ Videos (${event.videos.length} total, ${event.changedCount} item(s) to update):`));
437
+ for (const v of event.videos) {
438
+ if (v.changed) {
439
+ console.log(` • ${pc.cyan(v.filename)} ${pc.dim(`(${v.videoId})`)}`);
440
+ console.log(` ${pc.dim('Old:')} ${v.oldTitle}`);
441
+ console.log(` ${pc.green('New:')} ${pc.bold(v.newTitle)}`);
442
+ } else {
443
+ console.log(` • ${pc.dim(v.filename)} ${pc.dim(`(${v.videoId}) — unchanged`)}`);
444
+ }
445
+ }
446
+ console.log();
447
+ console.log(
448
+ pc.bold(`Estimated API Quota: `) +
449
+ pc.green(pc.bold(`${event.estimatedQuota} units`)) +
450
+ pc.dim(` / ${DAILY_DEFAULT_QUOTA} daily default units`)
451
+ );
452
+ console.log();
453
+ } else if (event.type === 'playlist_update_start') {
454
+ process.stdout.write(` ${pc.cyan('šŸ“‘')} Updating playlist title... `);
455
+ } else if (event.type === 'playlist_update_success') {
456
+ console.log(pc.green('UPDATED!'));
457
+ } else if (event.type === 'video_update_start') {
458
+ process.stdout.write(` ${pc.cyan('šŸŽ¬')} [${event.index + 1}/${event.total}] Updating "${event.item.filename}"... `);
459
+ } else if (event.type === 'video_update_success') {
460
+ console.log(pc.green('UPDATED!'));
461
+ } else if (event.type === 'metadata_file_saved') {
462
+ console.log(` ${pc.dim('šŸ“„ Saved updated titles to metadata.json')}`);
463
+ }
464
+ },
465
+ });
466
+
467
+ if (!isQuiet) {
468
+ if (options.dryRun) {
469
+ console.log(pc.yellow(`✨ Dry run complete. No YouTube updates were performed.`));
470
+ console.log();
471
+ process.exit(0);
472
+ }
473
+
474
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
475
+ console.log(pc.green(pc.bold(`šŸŽ‰ All titles and metadata updated successfully!`)));
476
+ console.log();
477
+ }
478
+ } catch (err) {
479
+ console.error(pc.red(`\nāŒ Error: ${err.message}\n`));
480
+ process.exit(1);
481
+ }
482
+ });
483
+
484
+ program
485
+ .command('search-media')
486
+ .description('Search and preview ranked stock media from Pexels and Pixabay without downloading')
487
+ .argument('<query>', 'Search query term')
488
+ .option('--type <video|photo>', 'Media type to search (default: video)', 'video')
489
+ .option('--provider <pexels|pixabay|auto>', 'Provider to use (default: auto)', 'auto')
490
+ .option('--orientation <landscape|portrait|square>', 'Orientation filter')
491
+ .option('--min-width <px>', 'Minimum width in pixels')
492
+ .option('--min-height <px>', 'Minimum height in pixels')
493
+ .option('--min-duration <seconds>', 'Minimum duration in seconds (video only)')
494
+ .option('--max-duration <seconds>', 'Maximum duration in seconds (video only)')
495
+ .option('--count <n>', 'Number of results to show (default: 10)', '10')
496
+ .option('--safe-search', 'Enable safe search filtering', true)
497
+ .option('--pexels-key <key>', 'Pexels API key')
498
+ .option('--pixabay-key <key>', 'Pixabay API key')
499
+ .option('--json', 'Print raw structured JSON results instead of a table', false)
500
+ .option('-q, --quiet', 'Minimal output mode', false)
501
+ .action(async (query, options) => {
502
+ const isQuiet = options.quiet;
503
+ const count = parseInt(options.count, 10) || 10;
504
+
505
+ try {
506
+ const results = await searchMedia({
507
+ query,
508
+ type: options.type,
509
+ provider: options.provider,
510
+ orientation: options.orientation,
511
+ safeSearch: options.safeSearch !== false,
512
+ pexelsKey: options.pexelsKey,
513
+ pixabayKey: options.pixabayKey,
514
+ });
515
+
516
+ const criteria = {
517
+ orientation: options.orientation || 'landscape',
518
+ minWidth: options.minWidth ? parseInt(options.minWidth, 10) : undefined,
519
+ minHeight: options.minHeight ? parseInt(options.minHeight, 10) : undefined,
520
+ minDuration: options.minDuration ? parseFloat(options.minDuration) : undefined,
521
+ maxDuration: options.maxDuration ? parseFloat(options.maxDuration) : undefined,
522
+ avoidReuse: false,
523
+ };
524
+
525
+ const ranked = rankCandidates(results, criteria).slice(0, count);
526
+
527
+ if (options.json) {
528
+ console.log(JSON.stringify(ranked.map(r => ({
529
+ ...r.candidate,
530
+ score: Math.round(r.score * 1000) / 1000,
531
+ breakdown: r.breakdown,
532
+ })), null, 2));
533
+ return;
534
+ }
535
+
536
+ if (!isQuiet) {
537
+ console.log();
538
+ console.log(pc.bold(pc.cyan(`šŸ” Stock Media Search Results`)) + ` ${pc.dim(`("${query}")`)}`);
539
+ console.log(pc.dim(`──────────────────────────────────────────────────────────────────────────────────────────`));
540
+ if (ranked.length === 0) {
541
+ console.log(pc.yellow(`No matching media assets found.`));
542
+ console.log();
543
+ return;
544
+ }
545
+
546
+ console.log(
547
+ pc.bold(
548
+ `Rank Provider Asset ID Dimensions Duration Score Creator Page URL`
549
+ )
550
+ );
551
+ console.log(pc.dim(`──────────────────────────────────────────────────────────────────────────────────────────`));
552
+
553
+ ranked.forEach((item, idx) => {
554
+ const c = item.candidate;
555
+ const rankStr = `#${idx + 1}`.padEnd(6);
556
+ const provStr = c.provider.padEnd(10);
557
+ const idStr = String(c.id).padEnd(10);
558
+ const dimStr = `${c.width}x${c.height}`.padEnd(12);
559
+ const durStr = (c.duration ? `${c.duration.toFixed(1)}s` : 'N/A').padEnd(10);
560
+ const scoreStr = item.score.toFixed(3).padEnd(8);
561
+ const creatorStr = (c.creator?.name || 'Unknown').slice(0, 18).padEnd(20);
562
+ const urlStr = pc.blue(pc.underline(c.pageUrl));
563
+
564
+ console.log(`${pc.green(rankStr)}${provStr}${idStr}${dimStr}${durStr}${pc.yellow(scoreStr)}${creatorStr}${urlStr}`);
565
+ });
566
+ console.log();
567
+ console.log(pc.dim(`Showing top ${ranked.length} of ${results.length} total found items.`));
568
+ console.log();
569
+ }
570
+ } catch (err) {
571
+ console.error(pc.red(`\nāŒ Error: ${err.message}\n`));
572
+ process.exit(1);
573
+ }
574
+ });
575
+
576
+ program
577
+ .command('resolve-media')
578
+ .description('Search, download, and write back stock media to metadata.json')
579
+ .argument('[directory]', 'Target folder containing metadata.json (default: current directory)', '.')
580
+ .option('-d, --dir <path>', 'Explicit target directory path')
581
+ .option('-m, --metadata <file>', 'Custom path to metadata.json file')
582
+ .option('--provider <pexels|pixabay|auto>', 'Provider to use: pexels, pixabay, auto (default: auto)')
583
+ .option('--type <video|photo>', 'Media type to resolve (default: video)')
584
+ .option('--orientation <landscape|portrait|square>', 'Orientation filter')
585
+ .option('--compose', 'Also build the final .mp4 via ffmpeg immediately', false)
586
+ .option('--force', 'Re-resolve entries that already have resolvedMedia', false)
587
+ .option('--allow-repeat-media', 'Disable the avoid-reuse novelty check for this run', false)
588
+ .option('--media-dir <path>', 'Override .media-cache directory location')
589
+ .option('--pexels-key <key>', 'Pexels API key')
590
+ .option('--pixabay-key <key>', 'Pixabay API key')
591
+ .option('-n, --dry-run', 'Show picks without downloading or writing metadata.json', false)
592
+ .option('-q, --quiet', 'Minimal output mode', false)
593
+ .action(async (directoryArg, options) => {
594
+ const targetDir = path.resolve(options.dir || directoryArg || process.cwd());
595
+ const isQuiet = options.quiet;
596
+
597
+ if (!isQuiet) {
598
+ console.log();
599
+ console.log(pc.bold(pc.cyan(`šŸŽ¬ Stock Media Resolver`)) + ` ${pc.dim(`v${pkg.version}`)}`);
600
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
601
+ console.log(`${pc.bold('šŸ“‚ Target Folder :')} ${pc.white(targetDir)}`);
602
+ if (options.metadata) {
603
+ console.log(`${pc.bold('šŸ“„ Metadata File :')} ${pc.white(path.resolve(options.metadata))}`);
604
+ }
605
+ if (options.compose) {
606
+ console.log(`${pc.bold('šŸŽžļø Video Compose :')} ${pc.green('ENABLED (building final .mp4 with ffmpeg)')}`);
607
+ }
608
+ if (options.dryRun) {
609
+ console.log(`${pc.yellow(pc.bold('šŸ” Mode :'))} ${pc.yellow('DRY RUN (no downloads or file writes)')}`);
610
+ }
611
+ console.log();
612
+ }
613
+
614
+ try {
615
+ const metadataPath = options.metadata ? path.resolve(options.metadata) : targetDir;
616
+ const result = await resolveMediaForMetadata(metadataPath, {
617
+ compose: options.compose,
618
+ force: options.force,
619
+ dryRun: options.dryRun,
620
+ allowRepeatMedia: options.allowRepeatMedia,
621
+ provider: options.provider,
622
+ type: options.type,
623
+ orientation: options.orientation,
624
+ cacheDir: options.mediaDir,
625
+ pexelsKey: options.pexelsKey,
626
+ pixabayKey: options.pixabayKey,
627
+ onEvent: (event) => {
628
+ if (isQuiet) return;
629
+
630
+ if (event.type === 'search_start') {
631
+ process.stdout.write(` šŸ” [${event.songKey}] Searching ${pc.cyan(event.provider)} for "${event.query}"... `);
632
+ } else if (event.type === 'search_results') {
633
+ console.log(pc.green(`Found ${event.totalFound} (top pick: #${event.topCandidate.id}, score: ${event.score.toFixed(3)})`));
634
+ } else if (event.type === 'download_start') {
635
+ process.stdout.write(` ā¬‡ļø Downloading asset ${event.provider}:${event.assetId}... `);
636
+ } else if (event.type === 'download_success') {
637
+ console.log(pc.green(`SAVED`));
638
+ } else if (event.type === 'compose_start') {
639
+ process.stdout.write(` šŸŽžļø Muxing audio + background into .mp4... `);
640
+ } else if (event.type === 'compose_success') {
641
+ console.log(pc.green(`COMPOSED: `) + pc.dim(event.outputPath));
642
+ } else if (event.type === 'item_resolved') {
643
+ console.log(pc.green(` āœ… Resolved `) + pc.bold(event.songKey) + ` -> ${event.resolvedMedia.credit}`);
644
+ console.log();
645
+ } else if (event.type === 'item_skipped') {
646
+ console.log(pc.dim(` ā­ļø Skipped ${event.songKey} (${event.reason})`));
647
+ } else if (event.type === 'item_error') {
648
+ console.error(pc.red(`\n āŒ Error for ${event.songKey}: ${event.error}`));
649
+ }
650
+ },
651
+ });
652
+
653
+ if (!isQuiet) {
654
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
655
+ console.log(pc.bold(`šŸ“Š Summary:`));
656
+ console.log(` • Resolved : ${pc.green(pc.bold(result.resolved.length))}`);
657
+ console.log(` • Skipped : ${pc.dim(result.skipped.length)}`);
658
+ if (result.errors.length > 0) {
659
+ console.log(` • Errors / Review: ${pc.red(pc.bold(result.errors.length))}`);
660
+ }
661
+ console.log();
662
+
663
+ if (options.dryRun) {
664
+ console.log(pc.yellow(`✨ Dry run complete. No media files or metadata were written.`));
665
+ console.log();
666
+ } else if (result.resolved.length > 0) {
667
+ console.log(pc.green(pc.bold(`šŸŽ‰ Media resolution complete!`)));
668
+ console.log(pc.dim(`Attribution summary saved to ATTRIBUTIONS.md`));
669
+ console.log();
670
+ }
671
+ }
672
+ } catch (err) {
673
+ console.error(pc.red(`\nāŒ Error: ${err.message}\n`));
674
+ process.exit(1);
675
+ }
676
+ });
677
+
678
+ program
679
+ .command('compose-video')
680
+ .description('Mux a background image or video and audio file into an MP4 video using FFmpeg')
681
+ .requiredOption('--background <file>', 'Path to background media file (video or image)')
682
+ .requiredOption('--audio <file>', 'Path to audio file (wav, mp3, etc.)')
683
+ .requiredOption('--output <file>', 'Output destination path for final .mp4')
684
+ .option('--mode <video-loop|video-trim|ken-burns>', 'Composition mode (default: video-loop)', 'video-loop')
685
+ .option('--width <px>', 'Output video width (default: 1920)', '1920')
686
+ .option('--height <px>', 'Output video height (default: 1080)', '1080')
687
+ .option('--fps <n>', 'Frames per second (default: 30)', '30')
688
+ .option('-n, --dry-run', 'Print the FFmpeg composition plan without executing', false)
689
+ .option('-q, --quiet', 'Minimal output mode', false)
690
+ .action(async (options) => {
691
+ const isQuiet = options.quiet;
692
+
693
+ if (!isQuiet) {
694
+ console.log();
695
+ console.log(pc.bold(pc.cyan(`šŸŽžļø Video Composer`)));
696
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
697
+ console.log(`${pc.bold('šŸŽ¬ Background :')} ${pc.white(path.resolve(options.background))}`);
698
+ console.log(`${pc.bold('šŸŽµ Audio Track:')} ${pc.white(path.resolve(options.audio))}`);
699
+ console.log(`${pc.bold('šŸ“¹ Output :')} ${pc.white(path.resolve(options.output))}`);
700
+ console.log(`${pc.bold('āš™ļø Mode :')} ${pc.cyan(options.mode)}`);
701
+ console.log();
702
+ }
703
+
704
+ try {
705
+ const res = await composeVideo({
706
+ backgroundPath: path.resolve(options.background),
707
+ audioPath: path.resolve(options.audio),
708
+ outputPath: path.resolve(options.output),
709
+ mode: options.mode,
710
+ targetWidth: parseInt(options.width, 10) || 1920,
711
+ targetHeight: parseInt(options.height, 10) || 1080,
712
+ fps: parseInt(options.fps, 10) || 30,
713
+ dryRun: options.dryRun,
714
+ });
715
+
716
+ if (!isQuiet) {
717
+ if (options.dryRun) {
718
+ console.log(pc.yellow(`šŸ” DRY RUN FFmpeg Plan:`));
719
+ console.log(` ${res.command}`);
720
+ console.log();
721
+ } else {
722
+ console.log(pc.green(pc.bold(`šŸŽ‰ Video successfully composed!`)));
723
+ console.log(` • Output: ${pc.underline(pc.blue(res.outputPath))}`);
724
+ console.log(` • Duration: ${res.duration?.toFixed(1)}s`);
725
+ console.log();
726
+ }
727
+ }
728
+ } catch (err) {
729
+ console.error(pc.red(`\nāŒ Error: ${err.message}\n`));
730
+ process.exit(1);
731
+ }
732
+ });
733
+
734
+ program
735
+ .command('apply-actions')
736
+ .description('Run configured video processing actions (shorten, replace-background, journey-overlay) on videos')
737
+ .argument('[directory]', 'Source folder containing metadata.json and video files (default: current directory)', '.')
738
+ .option('-d, --dir <path>', 'Explicit target directory path')
739
+ .option('-m, --metadata <file>', 'Custom path to metadata.json file')
740
+ .option('--actions-file <file>', 'Use a separate actions spec instead of reading actions from metadata.json')
741
+ .option('--output-suffix <suffix>', 'Suffix for the new output folder name')
742
+ .option('--output-dir <path>', 'Explicit output directory (overrides suffix-based naming)')
743
+ .option('--only <filename|songKey>', 'Restrict this run to a single entry')
744
+ .option('-f, --force', 'Re-run and overwrite an existing output for an entry', false)
745
+ .option('-n, --dry-run', 'Print the planned ffmpeg operations per entry, write nothing', false)
746
+ .option('--pexels-key <key>', 'Pexels API key')
747
+ .option('--pixabay-key <key>', 'Pixabay API key')
748
+ .option('--media-dir <path>', 'Override .media-cache directory location')
749
+ .option('-q, --quiet', 'Minimal output mode', false)
750
+ .action(async (directoryArg, options, command) => {
751
+ const parentOpts = command?.parent?.opts() || {};
752
+ const dryRun = Boolean(options.dryRun || parentOpts.dryRun);
753
+ const force = Boolean(options.force || parentOpts.force);
754
+ const metadata = options.metadata || parentOpts.metadata;
755
+ const targetDir = path.resolve(options.dir || parentOpts.dir || directoryArg || process.cwd());
756
+ const isQuiet = Boolean(options.quiet || parentOpts.quiet);
757
+
758
+ if (!isQuiet) {
759
+ console.log();
760
+ console.log(pc.bold(pc.cyan(`šŸŽ¬ Video Actions Pipeline`)) + ` ${pc.dim(`v${pkg.version}`)}`);
761
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
762
+ console.log(`${pc.bold('šŸ“‚ Source Folder :')} ${pc.white(targetDir)}`);
763
+ if (metadata) {
764
+ console.log(`${pc.bold('šŸ“„ Metadata File :')} ${pc.white(path.resolve(metadata))}`);
765
+ }
766
+ if (options.actionsFile) {
767
+ console.log(`${pc.bold('šŸ“‹ Actions Spec :')} ${pc.white(path.resolve(options.actionsFile))}`);
768
+ }
769
+ if (options.outputDir) {
770
+ console.log(`${pc.bold('šŸ“ Target Output :')} ${pc.white(path.resolve(options.outputDir))}`);
771
+ }
772
+ if (options.outputSuffix) {
773
+ console.log(`${pc.bold('šŸ·ļø Output Suffix :')} ${pc.cyan(options.outputSuffix)}`);
774
+ }
775
+ if (options.only) {
776
+ console.log(`${pc.bold('šŸŽÆ Filter Entry :')} ${pc.yellow(options.only)}`);
777
+ }
778
+ if (dryRun) {
779
+ console.log(`${pc.yellow(pc.bold('šŸ” Mode :'))} ${pc.yellow('DRY RUN (no file writes or transcoding)')}`);
780
+ }
781
+ if (force) {
782
+ console.log(`${pc.magenta(pc.bold('⚔ Force :'))} ${pc.magenta('ENABLED (overwriting existing output files)')}`);
783
+ }
784
+ console.log();
785
+ }
786
+
787
+ try {
788
+ const result = await batchApplyActions(targetDir, {
789
+ metadataFile: metadata,
790
+ actionsFile: options.actionsFile,
791
+ outputDir: options.outputDir,
792
+ outputSuffix: options.outputSuffix,
793
+ only: options.only,
794
+ force,
795
+ dryRun,
796
+ pexelsKey: options.pexelsKey,
797
+ pixabayKey: options.pixabayKey,
798
+ cacheDir: options.mediaDir,
799
+ onEvent: (event) => {
800
+ if (isQuiet) return;
801
+
802
+ if (event.type === 'actions_plan') {
803
+ console.log(
804
+ pc.bold(`Found `) +
805
+ pc.green(pc.bold(`${event.total}`)) +
806
+ pc.bold(` item(s) to process.`)
807
+ );
808
+ console.log(`${pc.bold('šŸ“ Output Directory:')} ${pc.cyan(event.outputDir)}`);
809
+ console.log();
810
+ } else if (event.type === 'entry_start') {
811
+ console.log(` šŸŽ¬ [${event.index + 1}/${event.total}] Processing ${pc.bold(event.songKey)} (${event.actionsCount} action(s))...`);
812
+ } else if (event.type === 'action_start') {
813
+ console.log(` āš™ļø [Action ${event.actionIndex + 1}/${event.totalActions}] Running ${pc.cyan(event.actionType)}...`);
814
+ } else if (event.type === 'action_complete') {
815
+ console.log(pc.green(` āœ… Action complete:`), pc.dim(event.actionType));
816
+ } else if (event.type === 'entry_success') {
817
+ if (dryRun && event.steps) {
818
+ for (const step of event.steps) {
819
+ console.log(` ${pc.dim('↳')} ${step}`);
820
+ }
821
+ } else {
822
+ console.log(pc.green(` āœ… Finished ${event.songKey} ->`), pc.dim(event.outputPath));
823
+ }
824
+ console.log();
825
+ } else if (event.type === 'entry_skipped') {
826
+ console.log(pc.dim(` ā­ļø [SKIPPED] ${event.songKey} (${event.reason})`));
827
+ console.log();
828
+ } else if (event.type === 'entry_error') {
829
+ console.error(pc.red(` āŒ Error on ${event.songKey}: ${event.error.message}`));
830
+ console.log();
831
+ } else if (event.type === 'action_warn') {
832
+ console.warn(pc.yellow(` āš ļø Warning: ${event.message}`));
833
+ } else if (event.type === 'metadata_cloned') {
834
+ console.log(pc.green(` šŸ“„ Cloned and updated metadata to:`), pc.dim(event.outputPath));
835
+ console.log();
836
+ }
837
+ },
838
+ });
839
+
840
+ if (!isQuiet) {
841
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
842
+ console.log(pc.bold(`šŸ“Š Actions Summary:`));
843
+ console.log(` • Total items : ${pc.bold(result.total)}`);
844
+ console.log(` • Processed : ${pc.green(pc.bold(result.processed.length))}`);
845
+ console.log(` • Skipped : ${pc.dim(result.skipped.length)}`);
846
+ if (result.failed.length > 0) {
847
+ console.log(` • Failed : ${pc.red(pc.bold(result.failed.length))}`);
848
+ }
849
+ console.log();
850
+
851
+ if (dryRun) {
852
+ console.log(pc.yellow(`✨ Dry run complete. No files were written or modified.`));
853
+ console.log();
854
+ } else if (result.failed.length > 0) {
855
+ console.log(pc.red(`āš ļø Completed with ${result.failed.length} failure(s).`));
856
+ process.exit(1);
857
+ } else {
858
+ console.log(pc.green(pc.bold(`šŸŽ‰ All video actions successfully applied!`)));
859
+ console.log(` • Output directory: ${pc.underline(pc.cyan(result.outputDir))}`);
860
+ console.log();
861
+ }
862
+ }
863
+ } catch (err) {
864
+ console.error(pc.red(`\nāŒ Error: ${err.message}\n`));
865
+ process.exit(1);
866
+ }
867
+ });
868
+
869
+ program
870
+ .command('preview-journey')
871
+ .description('Generate and preview the journey overlay graphic as a standalone PNG image')
872
+ .argument('<songKey>', 'Song key or filename to preview')
873
+ .option('-d, --dir <path>', 'Source folder containing metadata.json (default: current directory)')
874
+ .option('-m, --metadata <file>', 'Custom path to metadata.json file')
875
+ .option('--center-text <text>', 'Preview a specific center text without editing metadata.json')
876
+ .option('--concept-index <n>', 'Index of concept in centerConcepts to preview (default: 0)')
877
+ .option('-o, --output <file>', 'Output path for PNG file (default: ./journey-preview-<songKey>.png)')
878
+ .option('-q, --quiet', 'Minimal output mode', false)
879
+ .action(async (songKey, options) => {
880
+ const isQuiet = options.quiet;
881
+
882
+ if (!isQuiet) {
883
+ console.log();
884
+ console.log(pc.bold(pc.cyan(`šŸ–¼ļø Journey Overlay Preview`)) + ` ${pc.dim(`v${pkg.version}`)}`);
885
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
886
+ console.log(`${pc.bold('šŸŽµ Song Key :')} ${pc.white(songKey)}`);
887
+ if (options.centerText) {
888
+ console.log(`${pc.bold('šŸ’¬ Center Text:')} ${pc.green(JSON.stringify(options.centerText))}`);
889
+ }
890
+ if (options.conceptIndex !== undefined) {
891
+ console.log(`${pc.bold('šŸ”¢ Concept Idx:')} ${pc.cyan(options.conceptIndex)}`);
892
+ }
893
+ console.log();
894
+ }
895
+
896
+ try {
897
+ const res = await previewJourney(songKey, {
898
+ dir: options.dir,
899
+ metadata: options.metadata,
900
+ centerText: options.centerText,
901
+ conceptIndex: options.conceptIndex,
902
+ output: options.output,
903
+ });
904
+
905
+ if (!isQuiet) {
906
+ console.log(pc.green(pc.bold(`šŸŽ‰ Preview generated!`)));
907
+ console.log(` • File : ${pc.underline(pc.blue(res.outputPath))}`);
908
+ console.log(` • Resolution: ${res.width}x${res.height}`);
909
+ console.log(` • Tracks : ${res.tracksCount}`);
910
+ console.log();
911
+ }
912
+ } catch (err) {
913
+ console.error(pc.red(`\nāŒ Error: ${err.message}\n`));
914
+ process.exit(1);
915
+ }
916
+ });
917
+
301
918
  program.parse(process.argv);
919
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x12i/youtube-video-uploader-cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "CLI tool to batch upload MP4 videos to YouTube via YouTube Data API v3 with OAuth2, metadata.json config, playlist assignment, and smart upload-state resume.",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -44,9 +44,15 @@
44
44
  "author": "",
45
45
  "license": "MIT",
46
46
  "dependencies": {
47
+ "@resvg/resvg-js": "^2.6.2",
47
48
  "commander": "^13.1.0",
49
+ "ffmpeg-static": "^5.2.0",
50
+ "ffprobe-static": "^3.1.0",
51
+ "fluent-ffmpeg": "^2.1.3",
48
52
  "googleapis": "^144.0.0",
53
+ "mime-types": "^2.1.35",
49
54
  "open": "^10.1.0",
55
+ "p-limit": "^6.1.0",
50
56
  "picocolors": "^1.1.1"
51
57
  }
52
58
  }