@x12i/youtube-video-uploader-cli 1.2.0 → 1.4.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/README.md CHANGED
@@ -1,18 +1,22 @@
1
1
  # @x12i/youtube-video-uploader-cli 📺
2
2
 
3
- > Automated batch video uploader for YouTube Data API v3 with OAuth2 authentication, playlist assignment, quota estimation, and smart upload-state resume.
3
+ > Automated batch video uploader for YouTube Data API v3 with OAuth2 authentication, album/playlist metadata, safe-quota batching, playlist video swap, and smart upload-state resume.
4
4
 
5
5
  ---
6
6
 
7
7
  ## 🚀 Features
8
8
 
9
- - **🔐 Interactive OAuth2 Authentication**: Authenticates with YouTube using Google OAuth2. Features a local loopback server to automatically capture the callback redirect, with a terminal prompt fallback.
10
- - **💾 Token Persistence**: Caches tokens locally in `token.json` so you only need to authorize once. Automatically handles token refresh.
11
- - **📄 Metadata-Driven Batch Uploads**: Configure titles, descriptions, tags, category, privacy, and playlist via a simple `metadata.json` file.
12
- - **⚡ Smart Skip & Resume**: Saves upload status to `.upload-history.json` and skips previously uploaded files on rerun to protect your API quota.
13
- - **📑 Playlist Integration**: Automatically adds uploaded videos to a specified YouTube Playlist (`playlistId`).
14
- - **📊 Quota Estimation**: Calculates estimated YouTube Data API units (1,600 units/video, 50 units/playlist addition) and warns if approaching the standard 10,000 units/day limit.
15
- - **🔍 Dry Run Mode**: Validate your `metadata.json`, inspect files, and check estimated quota before uploading.
9
+ - **🔐 Interactive OAuth2 Authentication**: Local loopback server to capture the callback redirect, with a terminal prompt fallback. Scopes: `youtube.upload` + `youtube.force-ssl`.
10
+ - **💾 Token Persistence**: Caches tokens in `token.json` and refreshes automatically.
11
+ - **📄 Metadata-Driven Batch Uploads**: Flat `videos[]` schema **or** rich album/`songs` + `playlist` + `uploadPolicy` + `{{template}}` variables.
12
+ - **📑 Playlist Auto-Create**: Creates a playlist from `playlist.snippet` when no `playlistId` is set, then inserts videos at their metadata positions.
13
+ - **⚡ Smart Skip & Resume**: Saves status to `.upload-history.json` and skips already-uploaded files on rerun.
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
+ - **📊 Quota Estimation**: Warns when estimated units approach the standard 10,000/day limit.
16
+ - **🔁 Playlist Video Swap**: `swap` subcommand replaces a playlist entry in place; old video is unlisted, never deleted. Resumable via `.swap-history.json`.
17
+ - **🏷️ 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
+ - **🎬 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.
19
+ - **🔍 Dry Run Mode**: Validate metadata and estimate quota before uploading or updating.
16
20
 
17
21
  ---
18
22
 
@@ -39,6 +43,9 @@ Before using the uploader, you need OAuth2 credentials from Google Cloud:
39
43
  ```bash
40
44
  # In the folder containing your .mp4 files and metadata.json:
41
45
  npx @x12i/youtube-video-uploader-cli
46
+
47
+ # Cap under daily quota (~5–6 videos), defer the rest:
48
+ npx @x12i/youtube-video-uploader-cli --safe-quota
42
49
  ```
43
50
 
44
51
  ### Install globally
@@ -52,12 +59,12 @@ yt-uploader [folder]
52
59
  yvu-cli [folder]
53
60
  ```
54
61
 
62
+ Credentials can also come from environment variables (`YOUTUBE_CLIENT_ID` / `YOUTUBE_CLIENT_SECRET`, or `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`) or a nearby `.env` file.
63
+
55
64
  ---
56
65
 
57
66
  ## 📂 Folder Structure & `metadata.json`
58
67
 
59
- Place your videos and a `metadata.json` file in your target folder:
60
-
61
68
  ```text
62
69
  my-videos/
63
70
  ├── metadata.json
@@ -66,7 +73,7 @@ my-videos/
66
73
  └── track3.mp4
67
74
  ```
68
75
 
69
- ### `metadata.json` Schema
76
+ ### Flat `videos[]` schema
70
77
 
71
78
  ```json
72
79
  {
@@ -90,6 +97,72 @@ my-videos/
90
97
  }
91
98
  ```
92
99
 
100
+ ### Rich album / `songs` schema
101
+
102
+ Supports album metadata, upload policy, playlist auto-create, ordered keys, and `{{playlistUrl}}` / `{{channelUrl}}` templates:
103
+
104
+ ```json
105
+ {
106
+ "channel": {
107
+ "name": "My Artist",
108
+ "channelUrl": "https://www.youtube.com/@myartist"
109
+ },
110
+ "album": {
111
+ "title": "GRAY TO LIGHT",
112
+ "artist": "My Artist"
113
+ },
114
+ "templateVariables": {
115
+ "channelUrl": "https://www.youtube.com/@myartist",
116
+ "playlistUrl": null
117
+ },
118
+ "uploadPolicy": {
119
+ "initialVideoPrivacyStatus": "private",
120
+ "categoryId": "10",
121
+ "notifySubscribersOnInitialUpload": false
122
+ },
123
+ "playlist": {
124
+ "key": "gray_to_light",
125
+ "orderedSongKeys": ["01-first.mp4", "02-second.mp4"],
126
+ "youtube": {
127
+ "snippet": {
128
+ "title": "GRAY TO LIGHT — Full Album",
129
+ "description": "Full album playlist.\n\nChannel: {{channelUrl}}"
130
+ },
131
+ "status": { "privacyStatus": "private" }
132
+ }
133
+ },
134
+ "songs": {
135
+ "01-first.mp4": {
136
+ "trackNumber": 1,
137
+ "playlistPosition": 0,
138
+ "youtube": {
139
+ "snippet": {
140
+ "title": "First Song — My Artist | GRAY TO LIGHT",
141
+ "description": "Track 1.\n\nPlaylist: {{playlistUrl}}",
142
+ "tags": ["album", "music"]
143
+ },
144
+ "status": { "privacyStatus": "private" }
145
+ }
146
+ },
147
+ "02-second.mp4": {
148
+ "trackNumber": 2,
149
+ "playlistPosition": 1,
150
+ "youtube": {
151
+ "snippet": {
152
+ "title": "Second Song — My Artist | GRAY TO LIGHT",
153
+ "description": "Track 2.",
154
+ "tags": ["album", "music"]
155
+ }
156
+ }
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ If `playlistId` is missing but `playlist.youtube.snippet` is present, the uploader creates the playlist on first run and stores its ID in `.upload-history.json`. Template variables are re-interpolated after creation so `{{playlistUrl}}` resolves correctly.
163
+
164
+ If no `videos` / `songs` entries exist, all `.mp4` files in the folder are auto-discovered.
165
+
93
166
  #### Category IDs:
94
167
  - `"10"` = Music (default)
95
168
  - `"22"` = People & Blogs
@@ -100,6 +173,8 @@ my-videos/
100
173
 
101
174
  ## ⚙️ CLI Options Reference
102
175
 
176
+ ### Batch upload (default command)
177
+
103
178
  ```text
104
179
  Usage: youtube-video-uploader [directory] [options]
105
180
 
@@ -111,7 +186,10 @@ Options:
111
186
  -d, --dir <path> Explicit target directory path
112
187
  -m, --metadata <file> Custom path to metadata.json file
113
188
  -f, --force Force re-upload of already uploaded videos (default: false)
114
- -n, --dry-run Preview video uploads and estimate quota without uploading (default: false)
189
+ -n, --dry-run Preview video uploads and estimate quota without uploading
190
+ -l, --limit <number> Maximum number of videos to upload in this run
191
+ --safe-quota Cap batch under ~9,950 daily units (~5–6 videos); defer the rest
192
+ --max-quota <units> Hard API quota unit cap for this run
115
193
  --client-id <id> Google OAuth Client ID
116
194
  --client-secret <secret> Google OAuth Client Secret
117
195
  --client-secrets-file <file> Path to client_secret.json downloaded from Google Cloud
@@ -120,6 +198,58 @@ Options:
120
198
  -h, --help Display help
121
199
  ```
122
200
 
201
+ ### `swap` subcommand
202
+
203
+ ```text
204
+ Usage: youtube-video-uploader swap <videoFile> [options]
205
+
206
+ Arguments:
207
+ videoFile Path to the new MP4 video file
208
+
209
+ Options:
210
+ --playlist <id> Playlist ID containing the old video (required)
211
+ --old-video <videoId> Video ID of the old video to replace (required)
212
+ -k, --key <songKey> Stable song key for swap state (default: video file basename)
213
+ --title <title> Override the new video title (default: carried over from old)
214
+ --state-file <file> Swap state filename (default: .swap-history.json)
215
+ --token-file <file> OAuth token path (default: token.json in cwd)
216
+ --client-secrets-file <file> Path to client_secret.json
217
+ --client-id <id> Google OAuth Client ID
218
+ --client-secret <secret> Google OAuth Client Secret
219
+ --fresh Discard prior swap state for this key and start over
220
+ -n, --dry-run Print the swap plan without any API calls
221
+ -q, --quiet Minimal output mode
222
+ -h, --help Display help
223
+ ```
224
+
225
+ ### `update-metadata` / `update-titles` subcommand
226
+
227
+ ```text
228
+ Usage: youtube-video-uploader update-metadata [directory] [options]
229
+
230
+ Arguments:
231
+ directory Target folder containing metadata.json and upload history (default: ".")
232
+
233
+ Options:
234
+ -d, --dir <path> Explicit target directory path
235
+ -m, --metadata <file> Custom path to metadata.json file
236
+ --title-postfix <postfix> Postfix to append to each video title (e.g. " (long version)")
237
+ --playlist-postfix <postfix> Postfix to append to playlist title (e.g. " (long versions)")
238
+ --title-prefix <prefix> Prefix to prepend to each video title
239
+ --playlist-title <title> Explicit new title for the playlist
240
+ --video <videoId> Target a specific YouTube video ID
241
+ --title <title> Explicit new title for the targeted video
242
+ --playlist <playlistId> Target a specific YouTube playlist ID
243
+ --no-update-file Do not update metadata.json on disk
244
+ -n, --dry-run Preview changes without making any API calls
245
+ --token-file <file> OAuth token path (default: token.json in target dir)
246
+ --client-secrets-file <file> Path to client_secret.json
247
+ --client-id <id> Google OAuth Client ID
248
+ --client-secret <secret> Google OAuth Client Secret
249
+ -q, --quiet Minimal output mode
250
+ -h, --help Display help
251
+ ```
252
+
123
253
  ---
124
254
 
125
255
  ## 💡 Examples
@@ -129,7 +259,18 @@ Options:
129
259
  npx @x12i/youtube-video-uploader-cli ./my-videos --dry-run
130
260
  ```
131
261
 
132
- ### 2. Upload with credentials from environment variables
262
+ ### 2. Safe-quota batch (upload today’s slice, defer the rest)
263
+ ```bash
264
+ npx @x12i/youtube-video-uploader-cli ./my-videos --safe-quota
265
+ # Re-run the same command tomorrow for the next batch
266
+ ```
267
+
268
+ ### 3. Hard limit of N videos
269
+ ```bash
270
+ npx @x12i/youtube-video-uploader-cli ./my-videos --limit 5
271
+ ```
272
+
273
+ ### 4. Upload with credentials from environment variables
133
274
  ```bash
134
275
  export YOUTUBE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
135
276
  export YOUTUBE_CLIENT_SECRET="your-client-secret"
@@ -137,7 +278,7 @@ export YOUTUBE_CLIENT_SECRET="your-client-secret"
137
278
  npx @x12i/youtube-video-uploader-cli ./my-videos
138
279
  ```
139
280
 
140
- ### 3. Upload with downloaded `client_secret.json`
281
+ ### 5. Upload with downloaded `client_secret.json`
141
282
  ```bash
142
283
  npx @x12i/youtube-video-uploader-cli ./my-videos --client-secrets-file ./client_secret.json
143
284
  ```
@@ -151,6 +292,8 @@ Re-uploads a song (e.g. a new version with lyrics subtitles) and swaps it into a
151
292
  ```bash
152
293
  yt-uploader swap ./song-v2.mp4 --playlist PLxxxx --old-video VIDEOID
153
294
  yt-uploader swap ./song-v2.mp4 --playlist PLxxxx --old-video VIDEOID --dry-run
295
+ yt-uploader swap ./song-v2.mp4 --playlist PLxxxx --old-video VIDEOID --key "01-first.mp4" --title "First Song (Lyrics)"
296
+ yt-uploader swap ./song-v2.mp4 --playlist PLxxxx --old-video VIDEOID --fresh
154
297
  ```
155
298
 
156
299
  The swap runs as a resumable five-step state machine (state persisted to `.swap-history.json` after every step):
@@ -161,31 +304,212 @@ The swap runs as a resumable five-step state machine (state persisted to `.swap-
161
304
  4. **REMOVE** — remove the old playlist item
162
305
  5. **FINALIZE** — old video → unlisted, new video → public
163
306
 
164
- If any step fails, re-run the same command — completed steps are skipped automatically (no duplicate uploads or playlist entries). A missing old video aborts with a "needs manual review" flag instead of guessing.
307
+ If any step fails, re-run the same command — completed steps are skipped automatically (no duplicate uploads or playlist entries). A missing old video aborts with a "needs manual review" flag instead of guessing. Use `--fresh` to discard prior state for that key.
165
308
 
166
309
  **Quota cost:** ~150 shared-pool units + 1 upload-bucket unit per swap (well within the 10,000/day default pool).
167
310
 
168
311
  **Notes:**
169
- - OAuth scopes are now `youtube.upload` + `youtube.force-ssl` (narrower than before) — existing `token.json` files need a one-time re-consent.
312
+ - OAuth scopes are `youtube.upload` + `youtube.force-ssl` — existing `token.json` files need a one-time re-consent if issued under older scopes.
170
313
  - The target playlist must use **Manual** ordering for position-preserving inserts (set in YouTube Studio).
171
314
  - Videos from an unverified Google Cloud project are forced private until the project passes YouTube's API audit.
172
315
 
173
- ## 🛠️ Programmatic Node.js API
316
+ ---
174
317
 
175
- ```javascript
176
- import { batchUpload, uploadVideo, loadMetadata } from '@x12i/youtube-video-uploader-cli';
177
-
178
- const result = await batchUpload('./my-videos', {
179
- clientId: process.env.YOUTUBE_CLIENT_ID,
180
- clientSecret: process.env.YOUTUBE_CLIENT_SECRET,
181
- onEvent: (event) => {
182
- if (event.type === 'upload_success') {
183
- console.log(`Uploaded ${event.item.filename} -> ${event.record.url}`);
318
+ ## 🎬 Automated Stock Media Sourcing (Pexels + Pixabay)
319
+
320
+ If you have audio tracks (`.wav`, `.mp3`) and no visuals, the uploader can automatically query Pexels and Pixabay for relevant stock video/photo backgrounds, select the best candidate, download it, and compose `.mp4` videos via FFmpeg before uploading.
321
+
322
+ ### Environment Variables & Credentials
323
+
324
+ | Variable | Description | Aliases |
325
+ |---|---|---|
326
+ | `PEXELS_KEY` | Pexels API Key | `PEXEL_KEY` |
327
+ | `PIXABAY_KEY` | Pixabay API Key | `PICABAY_KEY` |
328
+ | `MEDIA_PROVIDER_ORDER` | Fallback search order (default: `pexels,pixabay`) | |
329
+
330
+ API keys can also be specified via CLI flags (`--pexels-key <key>`, `--pixabay-key <key>`) or in a `.env` file.
331
+
332
+ ### CLI Commands
333
+
334
+ #### 1. Preview search results (`search-media`)
335
+ Preview ranked candidates without downloading anything:
336
+ ```bash
337
+ yt-uploader search-media "calm night drive neon city" --type video --count 5
338
+ yt-uploader search-media "sunset over ocean" --provider pixabay --orientation landscape --json
339
+ ```
340
+
341
+ #### 2. Resolve media & write to `metadata.json` (`resolve-media`)
342
+ Search, download winning media to `.media-cache/assets/`, and optionally compose the final `.mp4`:
343
+ ```bash
344
+ # Resolve and download assets, update metadata.json
345
+ yt-uploader resolve-media ./my-songs
346
+
347
+ # Resolve, download, and build the final .mp4 with ffmpeg immediately
348
+ yt-uploader resolve-media ./my-songs --compose
349
+
350
+ # Dry-run preview
351
+ yt-uploader resolve-media ./my-songs --dry-run
352
+ ```
353
+
354
+ #### 3. Upload with automatic media resolution (`--auto-media`)
355
+ Run media resolution and ffmpeg composition automatically for missing video files before running the batch upload:
356
+ ```bash
357
+ yt-uploader ./my-songs --auto-media
358
+ ```
359
+
360
+ #### 4. Standalone Video Composer (`compose-video`)
361
+ Mux any background video or photo with an audio file:
362
+ ```bash
363
+ yt-uploader compose-video --background ./bg.mp4 --audio ./song.wav --output ./final.mp4
364
+ yt-uploader compose-video --background ./photo.jpg --audio ./song.wav --output ./final.mp4 --mode ken-burns
365
+ ```
366
+
367
+ ### `metadata.json` Configuration
368
+
369
+ Add optional top-level `mediaPolicy` or per-song `media` overrides:
370
+
371
+ ```json
372
+ {
373
+ "mediaPolicy": {
374
+ "provider": "auto",
375
+ "providerOrder": ["pexels", "pixabay"],
376
+ "mergeStrategy": "fallback",
377
+ "type": "video",
378
+ "orientation": "landscape",
379
+ "minWidth": 1280,
380
+ "minHeight": 720,
381
+ "safeSearch": true,
382
+ "avoidReuse": true,
383
+ "composeMode": "video-loop",
384
+ "cacheDir": ".media-cache"
385
+ },
386
+ "songs": {
387
+ "01_track.wav": {
388
+ "youtube": {
389
+ "snippet": {
390
+ "title": "Night Lights"
391
+ }
392
+ },
393
+ "media": {
394
+ "query": "neon city driving rain",
395
+ "type": "video",
396
+ "orientation": "landscape"
397
+ }
184
398
  }
399
+ }
400
+ }
401
+ ```
402
+
403
+ After resolution, `resolvedMedia` is saved directly to `metadata.json` and `ATTRIBUTIONS.md` is generated automatically with full licensing credits.
404
+
405
+ ---
406
+
407
+ ## 🎬 Batch Video Actions Pipeline
408
+
409
+ The actions pipeline allows non-destructive video modifications (shortening, background replacement, and playlist journey overlays) processed into a **new sibling folder** with cloned `metadata.json`:
410
+
411
+ ### Available Actions
412
+
413
+ 1. **`shorten`**: Cut video at `cutPoint`, smoothly fade out audio and video into the cut, then append a silent padding buffer.
414
+ 2. **`replace-background`**: Keep existing audio and swap the visual background with a freshly sourced stock video or photo.
415
+ 3. **`journey-overlay`**: Burn in a modern journey overlay graphic (either a sleek top-right step badge or playlist panel) and optional cycling center concepts.
416
+
417
+ ### `metadata.json` Actions Configuration
418
+
419
+ ```json
420
+ {
421
+ "defaultActions": [
422
+ { "type": "journey-overlay" }
423
+ ],
424
+ "journey": {
425
+ "enabled": true,
426
+ "style": "badge",
427
+ "position": "right",
428
+ "sidebarLabel": "stage",
429
+ "sidebarDisplayDuration": 4,
430
+ "theme": "dark",
431
+ "maxCenterChars": 60
185
432
  },
433
+ "songs": {
434
+ "01-first.mp4": {
435
+ "journeyStage": "Decision",
436
+ "actions": [
437
+ { "type": "shorten", "cutPoint": "02:15", "fadeDuration": 3, "paddingDuration": 2 },
438
+ {
439
+ "type": "journey-overlay",
440
+ "conceptsDisplayMode": "full",
441
+ "conceptDisplaySeconds": 7,
442
+ "conceptFadeSeconds": 1,
443
+ "centerConcepts": [
444
+ "You don't feel ready.",
445
+ "Move anyway."
446
+ ]
447
+ }
448
+ ]
449
+ },
450
+ "02-second.mp4": {
451
+ "journeyStage": "Sensation",
452
+ "actions": [
453
+ { "type": "replace-background", "query": "empty highway sunrise" }
454
+ ]
455
+ }
456
+ }
457
+ }
458
+ ```
459
+
460
+ ### CLI Commands
461
+
462
+ #### Apply Actions (`apply-actions`)
463
+ ```bash
464
+ # Process all videos and output to sibling folder (e.g. ./my-videos (short))
465
+ yt-uploader apply-actions ./my-videos
466
+
467
+ # Preview planned ffmpeg operations without modifying disk
468
+ yt-uploader apply-actions ./my-videos --dry-run
469
+
470
+ # Run only a specific video entry
471
+ yt-uploader apply-actions ./my-videos --only 01-first.mp4
472
+
473
+ # Overwrite existing outputs
474
+ yt-uploader apply-actions ./my-videos --force
475
+ ```
476
+
477
+ #### Preview Journey Overlay (`preview-journey`)
478
+ Generate a PNG preview of the journey graphic to iterate on sidebar and center text appearance without re-compositing videos:
479
+ ```bash
480
+ yt-uploader preview-journey 01-first.mp4 --dir ./my-videos
481
+ yt-uploader preview-journey 01-first.mp4 --center-text "Custom prompt question?" -o ./preview.png
482
+ ```
483
+
484
+ ---
485
+
486
+ ## 🛠️ Programmatic Node.js API
487
+
488
+ ```javascript
489
+ import {
490
+ searchMedia,
491
+ resolveMediaForMetadata,
492
+ composeVideo,
493
+ batchUpload,
494
+ } from '@x12i/youtube-video-uploader-cli';
495
+
496
+ // 1. Search stock media
497
+ const results = await searchMedia({
498
+ query: 'calm night drive neon city',
499
+ type: 'video',
500
+ provider: 'auto',
186
501
  });
187
502
 
188
- console.log(`Uploaded: ${result.uploaded.length}, Skipped: ${result.skipped.length}`);
503
+ // 2. Resolve media for metadata.json and compose videos
504
+ const { resolved, skipped, errors } = await resolveMediaForMetadata('./my-videos', {
505
+ compose: true,
506
+ provider: 'auto',
507
+ });
508
+
509
+ // 3. Batch upload
510
+ const uploadResult = await batchUpload('./my-videos', {
511
+ safeQuota: true,
512
+ });
189
513
  ```
190
514
 
191
515
  ---