@x12i/youtube-video-uploader-cli 1.0.0 → 1.2.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
@@ -144,6 +144,32 @@ npx @x12i/youtube-video-uploader-cli ./my-videos --client-secrets-file ./client_
144
144
 
145
145
  ---
146
146
 
147
+ ## 🔁 Replacing a Video in a Playlist (`swap`)
148
+
149
+ Re-uploads a song (e.g. a new version with lyrics subtitles) and swaps it into a playlist in place of the old video. The old video is **unlisted, never deleted**, so existing direct links keep working.
150
+
151
+ ```bash
152
+ yt-uploader swap ./song-v2.mp4 --playlist PLxxxx --old-video VIDEOID
153
+ yt-uploader swap ./song-v2.mp4 --playlist PLxxxx --old-video VIDEOID --dry-run
154
+ ```
155
+
156
+ The swap runs as a resumable five-step state machine (state persisted to `.swap-history.json` after every step):
157
+
158
+ 1. **UPLOAD** — upload the new video *unlisted* (metadata carried over from the old video)
159
+ 2. **LOCATE** — find the old video's playlist item + position
160
+ 3. **INSERT** — insert the new video at the old position
161
+ 4. **REMOVE** — remove the old playlist item
162
+ 5. **FINALIZE** — old video → unlisted, new video → public
163
+
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.
165
+
166
+ **Quota cost:** ~150 shared-pool units + 1 upload-bucket unit per swap (well within the 10,000/day default pool).
167
+
168
+ **Notes:**
169
+ - OAuth scopes are now `youtube.upload` + `youtube.force-ssl` (narrower than before) — existing `token.json` files need a one-time re-consent.
170
+ - The target playlist must use **Manual** ordering for position-preserving inserts (set in YouTube Studio).
171
+ - Videos from an unverified Google Cloud project are forced private until the project passes YouTube's API audit.
172
+
147
173
  ## 🛠️ Programmatic Node.js API
148
174
 
149
175
  ```javascript
package/bin/cli.js CHANGED
@@ -4,7 +4,9 @@ import { Command } from 'commander';
4
4
  import path from 'node:path';
5
5
  import fs from 'node:fs';
6
6
  import pc from 'picocolors';
7
- import { batchUpload, DAILY_DEFAULT_QUOTA } from '../src/index.js';
7
+ import { google } from 'googleapis';
8
+ import { batchUpload, DAILY_DEFAULT_QUOTA, swapSong, SwapStore, QUOTA_PER_SWAP } from '../src/index.js';
9
+ import { authenticate } from '../src/auth.js';
8
10
 
9
11
  // Read package.json for version
10
12
  const pkgPath = new URL('../package.json', import.meta.url);
@@ -21,6 +23,9 @@ program
21
23
  .option('-m, --metadata <file>', 'Custom path to metadata.json file')
22
24
  .option('-f, --force', 'Force re-upload of already uploaded videos', false)
23
25
  .option('-n, --dry-run', 'Preview video uploads and estimate quota without uploading', false)
26
+ .option('-l, --limit <number>', 'Maximum number of videos to upload in this run (e.g. 5 or 6)')
27
+ .option('--safe-quota', 'Cap upload batch to stay safely within standard daily 10,000 units quota (default ~5-6 videos)', false)
28
+ .option('--max-quota <units>', 'Maximum allowed API quota units for this run')
24
29
  .option('--client-id <id>', 'Google OAuth Client ID')
25
30
  .option('--client-secret <secret>', 'Google OAuth Client Secret')
26
31
  .option('--client-secrets-file <file>', 'Path to client_secret.json downloaded from Google Cloud')
@@ -38,6 +43,12 @@ program
38
43
  if (options.metadata) {
39
44
  console.log(`${pc.bold('📄 Metadata File :')} ${pc.white(path.resolve(options.metadata))}`);
40
45
  }
46
+ if (options.limit) {
47
+ console.log(`${pc.bold('🎯 Batch Limit :')} ${pc.cyan(`${options.limit} videos max`)}`);
48
+ }
49
+ if (options.safeQuota) {
50
+ console.log(`${pc.bold('🛡️ Safe Quota Mode :')} ${pc.green('ENABLED (capped under daily 10,000 units)')}`);
51
+ }
41
52
  if (options.dryRun) {
42
53
  console.log(`${pc.yellow(pc.bold('🔍 Mode :'))} ${pc.yellow('DRY RUN (no uploads will be made)')}`);
43
54
  }
@@ -56,6 +67,9 @@ program
56
67
  metadataFile: options.metadata,
57
68
  force: options.force,
58
69
  dryRun: options.dryRun,
70
+ limit: options.limit ? parseInt(options.limit, 10) : undefined,
71
+ safeQuota: options.safeQuota,
72
+ maxQuota: options.maxQuota ? parseInt(options.maxQuota, 10) : undefined,
59
73
  tokenFile,
60
74
  clientSecretsFile: options.clientSecretsFile,
61
75
  clientId: options.clientId,
@@ -68,18 +82,21 @@ program
68
82
  pc.bold(`Found `) +
69
83
  pc.green(pc.bold(`${event.total}`)) +
70
84
  pc.bold(` item(s): `) +
71
- pc.cyan(`${event.toProcess.length} to upload`) +
85
+ pc.cyan(`${event.toProcess.length} to upload now`) +
86
+ (event.deferred?.length ? `, ` + pc.yellow(`${event.deferred.length} deferred`) : '') +
72
87
  `, ` +
73
88
  pc.dim(`${event.skipped.length} skipped`)
74
89
  );
75
90
 
76
91
  if (event.playlistId) {
77
- console.log(pc.dim(`Playlist ID: ${event.playlistId}`));
92
+ console.log(pc.dim(`Playlist ID : ${event.playlistId}`));
93
+ } else if (event.needsPlaylistCreation) {
94
+ console.log(pc.cyan(`Playlist Creation : "${event.playlist.snippet?.title}"`));
78
95
  }
79
96
 
80
97
  console.log(
81
98
  pc.bold(`Estimated API Quota: `) +
82
- (event.estimatedQuota > DAILY_DEFAULT_QUOTA ? pc.red(pc.bold(`${event.estimatedQuota} units`)) : pc.cyan(`${event.estimatedQuota} units`)) +
99
+ (event.estimatedQuota > DAILY_DEFAULT_QUOTA ? pc.red(pc.bold(`${event.estimatedQuota} units`)) : pc.green(pc.bold(`${event.estimatedQuota} units`))) +
83
100
  pc.dim(` / ${DAILY_DEFAULT_QUOTA} daily default units`)
84
101
  );
85
102
 
@@ -88,12 +105,27 @@ program
88
105
  }
89
106
  console.log();
90
107
 
108
+ if (event.deferred?.length > 0) {
109
+ for (const d of event.deferred) {
110
+ console.log(` ${pc.yellow('⏳ [DEFERRED]')} ${pc.dim(d.filename)} ${pc.dim(`(${d.reason})`)}`);
111
+ }
112
+ console.log();
113
+ }
114
+
91
115
  if (event.skipped.length > 0) {
92
116
  for (const s of event.skipped) {
93
- console.log(` ${pc.dim('⏭️ [SKIPPED]')} ${pc.dim(s.filename)} ${pc.dim(`(${s.reason})`)}`);
117
+ console.log(` ${pc.dim('⏭️ [SKIPPED] ')} ${pc.dim(s.filename)} ${pc.dim(`(${s.reason})`)}`);
94
118
  }
95
119
  console.log();
96
120
  }
121
+ } else if (event.type === 'authenticated_channel') {
122
+ console.log(pc.green(`👤 Authenticated Channel: `) + pc.bold(event.channel.title) + pc.dim(` (${event.channel.customUrl || event.channel.id})`));
123
+ console.log();
124
+ } else if (event.type === 'playlist_create_start') {
125
+ console.log(` ${pc.cyan('📑')} Creating playlist: ${pc.bold(event.playlist.snippet?.title)}...`);
126
+ } else if (event.type === 'playlist_create_success') {
127
+ console.log(pc.green(` ✅ Playlist created! URL: `) + pc.underline(pc.blue(event.url)));
128
+ console.log();
97
129
  } else if (event.type === 'upload_start') {
98
130
  process.stdout.write(` ${pc.cyan('⬆️ ')} [${event.index + 1}/${event.total}] Uploading ${pc.bold(event.item.filename)} ("${event.item.title}")... `);
99
131
  } else if (event.type === 'upload_success') {
@@ -124,6 +156,9 @@ program
124
156
  console.log(` • Total items : ${pc.bold(result.total)}`);
125
157
  console.log(` • Uploaded : ${pc.green(pc.bold(result.uploaded.length))}`);
126
158
  console.log(` • Skipped : ${pc.dim(result.skipped.length)}`);
159
+ if (result.deferred?.length > 0) {
160
+ console.log(` • Deferred : ${pc.yellow(pc.bold(result.deferred.length))} ${pc.dim('(run again tomorrow for the next batch)')}`);
161
+ }
127
162
  if (result.failed.length > 0) {
128
163
  console.log(` • Failed : ${pc.red(pc.bold(result.failed.length))}`);
129
164
  }
@@ -132,6 +167,10 @@ program
132
167
  if (result.failed.length > 0) {
133
168
  console.log(pc.red(`⚠️ Batch completed with ${result.failed.length} failure(s).`));
134
169
  process.exit(1);
170
+ } else if (result.deferred?.length > 0) {
171
+ console.log(pc.green(pc.bold(`🎉 Batch completed! ${result.uploaded.length} videos uploaded today.`)));
172
+ console.log(pc.cyan(`👉 Run the exact same command tomorrow to upload the remaining ${result.deferred.length} videos.`));
173
+ console.log();
135
174
  } else if (result.uploaded.length > 0) {
136
175
  console.log(pc.green(pc.bold(`🎉 All videos successfully uploaded to YouTube!`)));
137
176
  console.log();
@@ -146,4 +185,117 @@ program
146
185
  }
147
186
  });
148
187
 
188
+ program
189
+ .command('swap')
190
+ .description('Replace a song\'s video in a YouTube playlist: upload the new video, swap the playlist entry in place, unlist (never delete) the old video')
191
+ .argument('<videoFile>', 'Path to the new MP4 video file')
192
+ .requiredOption('--playlist <id>', 'Playlist ID containing the old video')
193
+ .requiredOption('--old-video <videoId>', 'Video ID of the old video to replace')
194
+ .option('-k, --key <songKey>', 'Stable song key for swap state tracking (default: video file basename)')
195
+ .option('--title <title>', 'Override the new video title (default: carried over from the old video)')
196
+ .option('--state-file <file>', 'Swap state filename (default: .swap-history.json in target dir)')
197
+ .option('--token-file <file>', 'Path to store or read OAuth token (default: token.json in cwd)')
198
+ .option('--client-secrets-file <file>', 'Path to client_secret.json downloaded from Google Cloud')
199
+ .option('--client-id <id>', 'Google OAuth Client ID')
200
+ .option('--client-secret <secret>', 'Google OAuth Client Secret')
201
+ .option('--fresh', 'Discard any previous swap state for this key and start over', false)
202
+ .option('-n, --dry-run', 'Print the swap plan without any API calls', false)
203
+ .option('-q, --quiet', 'Minimal output mode', false)
204
+ .action(async (videoFile, options) => {
205
+ const isQuiet = options.quiet;
206
+ const videoPath = path.resolve(videoFile);
207
+ const songKey = options.key || path.basename(videoPath);
208
+ const stateDir = process.cwd();
209
+
210
+ if (!isQuiet) {
211
+ console.log();
212
+ console.log(pc.bold(pc.red(`🔁 Playlist Video Swap`)) + ` ${pc.dim(`v${pkg.version}`)}`);
213
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
214
+ console.log(`${pc.bold('🎬 New Video :')} ${pc.white(videoPath)}`);
215
+ console.log(`${pc.bold('🗑️ Old Video ID :')} ${pc.white(options.oldVideo)}`);
216
+ console.log(`${pc.bold('📑 Playlist ID :')} ${pc.white(options.playlist)}`);
217
+ console.log(`${pc.bold('🔑 Swap Key :')} ${pc.white(songKey)}`);
218
+ console.log(pc.dim(`Estimated quota : ~${QUOTA_PER_SWAP - 1} shared units + 1 upload`));
219
+ console.log();
220
+ }
221
+
222
+ if (!fs.existsSync(videoPath)) {
223
+ console.error(pc.red(`\n❌ Error: Video file not found: ${videoPath}\n`));
224
+ process.exit(1);
225
+ }
226
+
227
+ if (options.dryRun) {
228
+ if (!isQuiet) {
229
+ console.log(pc.yellow(`🔍 DRY RUN — the following steps would run:`));
230
+ console.log(` 1. UPLOAD — videos.insert (unlisted), metadata carried over from old video`);
231
+ console.log(` 2. LOCATE — playlistItems.list to find old item id + position`);
232
+ console.log(` 3. INSERT — playlistItems.insert at the old position`);
233
+ console.log(` 4. REMOVE — playlistItems.delete for the old item`);
234
+ console.log(` 5. FINALIZE — old video → unlisted, new video → public`);
235
+ console.log();
236
+ console.log(pc.yellow(`✨ Dry run complete. No API calls were made.`));
237
+ console.log();
238
+ }
239
+ process.exit(0);
240
+ }
241
+
242
+ try {
243
+ const tokenFile = options.tokenFile
244
+ ? path.resolve(options.tokenFile)
245
+ : path.join(stateDir, 'token.json');
246
+
247
+ const auth = await authenticate({
248
+ tokenFile,
249
+ clientSecretsFile: options.clientSecretsFile,
250
+ clientId: options.clientId,
251
+ clientSecret: options.clientSecret,
252
+ });
253
+ const youtube = google.youtube({ version: 'v3', auth });
254
+
255
+ const store = new SwapStore(stateDir, options.stateFile);
256
+ const existing = store.getRecord(songKey);
257
+ if (existing && !options.fresh && !isQuiet) {
258
+ console.log(pc.cyan(`↩️ Resuming swap in progress (last status: ${existing.status}${existing.lastError ? ` — ${existing.lastError}` : ''})`));
259
+ console.log();
260
+ }
261
+
262
+ const { record } = await swapSong({
263
+ youtube,
264
+ songKey,
265
+ newVideoPath: videoPath,
266
+ playlistId: options.playlist,
267
+ oldVideoId: options.oldVideo,
268
+ meta: options.title ? { title: options.title } : undefined,
269
+ stateDir,
270
+ stateFile: options.stateFile,
271
+ fresh: options.fresh,
272
+ onEvent: (event) => {
273
+ if (isQuiet) return;
274
+ if (event.type === 'swap_step') {
275
+ console.log(pc.green(` ✅ ${event.status.toUpperCase()}`));
276
+ } else if (event.type === 'swap_uploaded') {
277
+ console.log(pc.green(` ⬆️ Uploaded new video: `) + pc.underline(pc.blue(event.url)));
278
+ } else if (event.type === 'swap_error') {
279
+ console.error(pc.red(` ❌ Failed at swap step: ${event.error.message}`));
280
+ if (event.needsManualReview) {
281
+ console.error(pc.yellow(` ⚠️ Needs manual review — the old video was not found in the playlist.`));
282
+ }
283
+ }
284
+ },
285
+ });
286
+
287
+ if (!isQuiet) {
288
+ console.log();
289
+ console.log(pc.green(pc.bold(`🎉 Swap complete!`)));
290
+ console.log(` • Playlist entry replaced: ${pc.bold(options.playlist)} (position preserved)`);
291
+ console.log(` • New video is public : ${pc.underline(pc.blue(`https://youtu.be/${record.newVideoId}`))}`);
292
+ console.log(` • Old video unlisted : ${pc.dim(`https://youtu.be/${options.oldVideo} (direct links still work)`)}`);
293
+ console.log();
294
+ }
295
+ } catch (err) {
296
+ console.error(pc.red(`\n❌ Error: ${err.message}\n`));
297
+ process.exit(1);
298
+ }
299
+ });
300
+
149
301
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x12i/youtube-video-uploader-cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",
package/src/auth.js CHANGED
@@ -8,18 +8,55 @@ import pc from 'picocolors';
8
8
 
9
9
  export const YOUTUBE_SCOPES = [
10
10
  'https://www.googleapis.com/auth/youtube.upload',
11
- 'https://www.googleapis.com/auth/youtube',
11
+ 'https://www.googleapis.com/auth/youtube.force-ssl',
12
12
  ];
13
13
 
14
14
  export const DEFAULT_REDIRECT_URI = 'http://localhost:3000/oauth2callback';
15
15
  export const DEFAULT_TOKEN_FILENAME = 'token.json';
16
16
 
17
+ /**
18
+ * Load .env file into process.env if present in current or parent directories.
19
+ */
20
+ export function loadDotEnv() {
21
+ let curr = process.cwd();
22
+ for (let i = 0; i < 5; i++) {
23
+ const envPath = path.join(curr, '.env');
24
+ if (fs.existsSync(envPath)) {
25
+ try {
26
+ const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
27
+ for (const line of lines) {
28
+ const trimmed = line.trim();
29
+ if (!trimmed || trimmed.startsWith('#')) continue;
30
+ const eqIdx = trimmed.indexOf('=');
31
+ if (eqIdx !== -1) {
32
+ const key = trimmed.slice(0, eqIdx).trim();
33
+ let val = trimmed.slice(eqIdx + 1).trim();
34
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
35
+ val = val.slice(1, -1);
36
+ }
37
+ if (!process.env[key]) {
38
+ process.env[key] = val;
39
+ }
40
+ }
41
+ }
42
+ break;
43
+ } catch {
44
+ // Ignore read errors
45
+ }
46
+ }
47
+ const parent = path.dirname(curr);
48
+ if (parent === curr) break;
49
+ curr = parent;
50
+ }
51
+ }
52
+
17
53
  /**
18
54
  * Extract Client ID and Client Secret from file or environment.
19
55
  * @param {object} options
20
56
  * @returns {{ clientId: string, clientSecret: string, redirectUri: string }}
21
57
  */
22
58
  export function resolveOAuthCredentials(options = {}) {
59
+ loadDotEnv();
23
60
  let clientId = options.clientId || process.env.YOUTUBE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID;
24
61
  let clientSecret = options.clientSecret || process.env.YOUTUBE_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET;
25
62
  const redirectUri = options.redirectUri || DEFAULT_REDIRECT_URI;
package/src/index.d.ts CHANGED
@@ -81,3 +81,76 @@ export const DAILY_DEFAULT_QUOTA: number;
81
81
  export const DEFAULT_CATEGORY_ID: string;
82
82
  export const DEFAULT_PRIVACY_STATUS: string;
83
83
  export const VALID_PRIVACY_STATUSES: Set<string>;
84
+
85
+ // ─── Playlist video swap ───────────────────────────────────────────────────
86
+
87
+ export type SwapStatus =
88
+ | 'pending'
89
+ | 'uploaded'
90
+ | 'located'
91
+ | 'inserted'
92
+ | 'removed'
93
+ | 'finalized'
94
+ | 'failed';
95
+
96
+ export interface SwapRecord {
97
+ songKey: string;
98
+ oldVideoId: string;
99
+ playlistId: string;
100
+ newVideoPath?: string;
101
+ newVideoId?: string;
102
+ oldPlaylistItemId?: string;
103
+ oldPosition?: number;
104
+ newPlaylistItemId?: string;
105
+ status: SwapStatus;
106
+ lastError?: string;
107
+ updatedAt?: string;
108
+ }
109
+
110
+ export class SwapStore {
111
+ constructor(targetFolder?: string, stateFilename?: string);
112
+ getRecord(songKey: string): SwapRecord | null;
113
+ initRecord(record: { songKey: string; playlistId: string; oldVideoId: string; newVideoPath?: string }): SwapRecord;
114
+ updateRecord(songKey: string, fields: Partial<SwapRecord>): SwapRecord;
115
+ listRecords(status?: SwapStatus): SwapRecord[];
116
+ }
117
+
118
+ export function uploadNewVideo(
119
+ youtube: youtube_v3.Youtube,
120
+ newVideoPath: string,
121
+ opts?: { oldVideoId?: string; meta?: VideoMetadata; retries?: number }
122
+ ): Promise<{ videoId: string; url: string }>;
123
+
124
+ export function findPlaylistItem(
125
+ youtube: youtube_v3.Youtube,
126
+ playlistId: string,
127
+ oldVideoId: string
128
+ ): Promise<{ playlistItemId: string; position: number } | null>;
129
+
130
+ export function insertAtPosition(
131
+ youtube: youtube_v3.Youtube,
132
+ playlistId: string,
133
+ newVideoId: string,
134
+ position: number
135
+ ): Promise<{ newPlaylistItemId: string }>;
136
+
137
+ export function removeOldItem(youtube: youtube_v3.Youtube, oldPlaylistItemId: string): Promise<void>;
138
+
139
+ export function finalizeSwap(youtube: youtube_v3.Youtube, oldVideoId: string, newVideoId: string): Promise<void>;
140
+
141
+ export function swapSong(params: {
142
+ youtube: youtube_v3.Youtube;
143
+ songKey: string;
144
+ newVideoPath: string;
145
+ playlistId: string;
146
+ oldVideoId: string;
147
+ meta?: VideoMetadata;
148
+ stateDir?: string;
149
+ stateFile?: string;
150
+ fresh?: boolean;
151
+ onEvent?: (event: { type: string; [key: string]: any }) => void;
152
+ }): Promise<{ record: SwapRecord }>;
153
+
154
+ export const QUOTA_PER_SWAP: number;
155
+ export const DEFAULT_SWAP_STATE_FILENAME: string;
156
+ export const SWAP_STATUSES: string[];
package/src/index.js CHANGED
@@ -9,3 +9,12 @@ export {
9
9
  QUOTA_PER_PLAYLIST_ITEM,
10
10
  DAILY_DEFAULT_QUOTA,
11
11
  } from './uploader.js';
12
+ export { SwapStore, DEFAULT_SWAP_STATE_FILENAME, SWAP_STATUSES } from './swap/store.js';
13
+ export {
14
+ uploadNewVideo,
15
+ findPlaylistItem,
16
+ insertAtPosition,
17
+ removeOldItem,
18
+ finalizeSwap,
19
+ } from './swap/steps.js';
20
+ export { swapSong, QUOTA_PER_SWAP } from './swap/swapSong.js';
package/src/metadata.js CHANGED
@@ -8,13 +8,29 @@ export const VALID_PRIVACY_STATUSES = new Set(['private', 'unlisted', 'public'])
8
8
  export const DEFAULT_CATEGORY_ID = '10'; // Music
9
9
  export const DEFAULT_PRIVACY_STATUS = 'private';
10
10
 
11
+ export function interpolateTemplates(text, variables = {}) {
12
+ if (!text || typeof text !== 'string') return text;
13
+ return text.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
14
+ const trimmed = key.trim();
15
+ return variables[trimmed] !== undefined && variables[trimmed] !== null ? variables[trimmed] : match;
16
+ });
17
+ }
18
+
11
19
  /**
12
20
  * Load, validate, and normalize metadata for batch video uploading.
21
+ * Supports both the flat videos array format and the rich album/playlist/songs schema.
13
22
  *
14
23
  * @param {string} targetFolder - Path to folder containing videos
15
24
  * @param {string} [customMetadataPath] - Optional custom path to metadata.json
16
25
  * @returns {{
17
26
  * playlistId: string | null,
27
+ * playlist?: {
28
+ * key?: string,
29
+ * snippet?: object,
30
+ * status?: object,
31
+ * publishStatusAfterQc?: string
32
+ * },
33
+ * templateVariables: Record<string, string | null>,
18
34
  * privacyStatus: 'private' | 'unlisted' | 'public',
19
35
  * categoryId: string,
20
36
  * videos: Array<{
@@ -24,6 +40,8 @@ export const DEFAULT_PRIVACY_STATUS = 'private';
24
40
  * tags: string[],
25
41
  * categoryId?: string,
26
42
  * privacyStatus?: 'private' | 'unlisted' | 'public',
43
+ * trackNumber?: number,
44
+ * playlistPosition?: number,
27
45
  * filePath: string
28
46
  * }>
29
47
  * }}
@@ -52,14 +70,101 @@ export function loadMetadata(targetFolder, customMetadataPath) {
52
70
 
53
71
  const defaultPrivacy = VALID_PRIVACY_STATUSES.has(rawConfig.privacyStatus)
54
72
  ? rawConfig.privacyStatus
55
- : DEFAULT_PRIVACY_STATUS;
73
+ : (rawConfig.uploadPolicy?.initialVideoPrivacyStatus && VALID_PRIVACY_STATUSES.has(rawConfig.uploadPolicy.initialVideoPrivacyStatus)
74
+ ? rawConfig.uploadPolicy.initialVideoPrivacyStatus
75
+ : DEFAULT_PRIVACY_STATUS);
76
+
77
+ const defaultCategory = String(
78
+ rawConfig.categoryId || rawConfig.uploadPolicy?.categoryId || DEFAULT_CATEGORY_ID
79
+ );
80
+
81
+ const templateVariables = {
82
+ channelUrl: rawConfig.templateVariables?.channelUrl || rawConfig.channel?.channelUrl || null,
83
+ playlistUrl: rawConfig.templateVariables?.playlistUrl || null,
84
+ ...(rawConfig.templateVariables || {}),
85
+ };
86
+
87
+ let playlistId = rawConfig.playlistId ? String(rawConfig.playlistId).trim() : null;
88
+ let playlist = null;
89
+
90
+ if (rawConfig.playlist) {
91
+ playlist = {
92
+ key: rawConfig.playlist.key || 'default_playlist',
93
+ snippet: rawConfig.playlist.youtube?.snippet || rawConfig.playlist.snippet || null,
94
+ status: rawConfig.playlist.youtube?.status || rawConfig.playlist.status || { privacyStatus: defaultPrivacy },
95
+ publishStatusAfterQc: rawConfig.playlist.publishStatusAfterQc,
96
+ };
97
+ if (rawConfig.playlist.playlistId) {
98
+ playlistId = String(rawConfig.playlist.playlistId).trim();
99
+ }
100
+ }
101
+
102
+ let rawVideos = [];
56
103
 
57
- const defaultCategory = String(rawConfig.categoryId || DEFAULT_CATEGORY_ID);
58
- const playlistId = rawConfig.playlistId ? String(rawConfig.playlistId).trim() : null;
104
+ if (rawConfig.songs && typeof rawConfig.songs === 'object' && !Array.isArray(rawConfig.songs)) {
105
+ const orderedKeys = Array.isArray(rawConfig.playlist?.orderedSongKeys)
106
+ ? rawConfig.playlist.orderedSongKeys
107
+ : Object.keys(rawConfig.songs);
59
108
 
60
- let rawVideos = Array.isArray(rawConfig.videos) ? rawConfig.videos : [];
109
+ const seen = new Set();
110
+
111
+ for (const songKey of orderedKeys) {
112
+ const songData = rawConfig.songs[songKey];
113
+ if (songData) {
114
+ seen.add(songKey);
115
+ const snippet = songData.youtube?.snippet || {};
116
+ const status = songData.youtube?.status || {};
117
+ const insertOptions = songData.youtube?.insertOptions || {};
118
+ rawVideos.push({
119
+ filename: songKey,
120
+ title: snippet.title || path.basename(songKey, path.extname(songKey)),
121
+ description: snippet.description || '',
122
+ tags: snippet.tags || [],
123
+ categoryId: snippet.categoryId || rawConfig.uploadPolicy?.categoryId || defaultCategory,
124
+ privacyStatus: status.privacyStatus || rawConfig.uploadPolicy?.initialVideoPrivacyStatus || defaultPrivacy,
125
+ defaultLanguage: snippet.defaultLanguage || rawConfig.uploadPolicy?.defaultLanguage || 'en',
126
+ defaultAudioLanguage: snippet.defaultAudioLanguage || rawConfig.uploadPolicy?.defaultAudioLanguage || 'en',
127
+ license: status.license || rawConfig.uploadPolicy?.license || 'youtube',
128
+ embeddable: status.embeddable !== undefined ? status.embeddable : true,
129
+ publicStatsViewable: status.publicStatsViewable !== undefined ? status.publicStatsViewable : true,
130
+ selfDeclaredMadeForKids: status.selfDeclaredMadeForKids !== undefined ? status.selfDeclaredMadeForKids : (rawConfig.uploadPolicy?.selfDeclaredMadeForKids ?? false),
131
+ notifySubscribers: insertOptions.notifySubscribers !== undefined ? insertOptions.notifySubscribers : (rawConfig.uploadPolicy?.notifySubscribersOnInitialUpload ?? false),
132
+ trackNumber: songData.trackNumber,
133
+ playlistPosition: songData.playlistPosition !== undefined ? songData.playlistPosition : songData.playlistItem?.position,
134
+ });
135
+ }
136
+ }
61
137
 
62
- // If no videos array was provided in metadata.json, auto-discover all .mp4 files in the folder
138
+ // Add any remaining songs not in orderedSongKeys
139
+ for (const [songKey, songData] of Object.entries(rawConfig.songs)) {
140
+ if (!seen.has(songKey)) {
141
+ const snippet = songData.youtube?.snippet || {};
142
+ const status = songData.youtube?.status || {};
143
+ const insertOptions = songData.youtube?.insertOptions || {};
144
+ rawVideos.push({
145
+ filename: songKey,
146
+ title: snippet.title || path.basename(songKey, path.extname(songKey)),
147
+ description: snippet.description || '',
148
+ tags: snippet.tags || [],
149
+ categoryId: snippet.categoryId || rawConfig.uploadPolicy?.categoryId || defaultCategory,
150
+ privacyStatus: status.privacyStatus || rawConfig.uploadPolicy?.initialVideoPrivacyStatus || defaultPrivacy,
151
+ defaultLanguage: snippet.defaultLanguage || rawConfig.uploadPolicy?.defaultLanguage || 'en',
152
+ defaultAudioLanguage: snippet.defaultAudioLanguage || rawConfig.uploadPolicy?.defaultAudioLanguage || 'en',
153
+ license: status.license || rawConfig.uploadPolicy?.license || 'youtube',
154
+ embeddable: status.embeddable !== undefined ? status.embeddable : true,
155
+ publicStatsViewable: status.publicStatsViewable !== undefined ? status.publicStatsViewable : true,
156
+ selfDeclaredMadeForKids: status.selfDeclaredMadeForKids !== undefined ? status.selfDeclaredMadeForKids : (rawConfig.uploadPolicy?.selfDeclaredMadeForKids ?? false),
157
+ notifySubscribers: insertOptions.notifySubscribers !== undefined ? insertOptions.notifySubscribers : (rawConfig.uploadPolicy?.notifySubscribersOnInitialUpload ?? false),
158
+ trackNumber: songData.trackNumber,
159
+ playlistPosition: songData.playlistPosition !== undefined ? songData.playlistPosition : songData.playlistItem?.position,
160
+ });
161
+ }
162
+ }
163
+ } else if (Array.isArray(rawConfig.videos)) {
164
+ rawVideos = rawConfig.videos;
165
+ }
166
+
167
+ // If no videos were defined, auto-discover all .mp4 files in the folder
63
168
  if (rawVideos.length === 0 && fs.existsSync(resolvedFolder)) {
64
169
  const files = fs.readdirSync(resolvedFolder);
65
170
  const mp4Files = files.filter(f => f.toLowerCase().endsWith('.mp4') && !f.startsWith('.'));
@@ -73,13 +178,13 @@ export function loadMetadata(targetFolder, customMetadataPath) {
73
178
 
74
179
  const videos = rawVideos.map((item, index) => {
75
180
  if (!item.filename) {
76
- throw new Error(`Video at index ${index} in metadata.json is missing required "filename" property.`);
181
+ throw new Error(`Video at index ${index} in metadata is missing required "filename" property.`);
77
182
  }
78
183
 
79
184
  const filename = String(item.filename);
80
185
  const filePath = path.isAbsolute(filename) ? filename : path.join(resolvedFolder, filename);
81
- const title = item.title ? String(item.title) : path.basename(filename, path.extname(filename));
82
- const description = item.description ? String(item.description) : '';
186
+ const rawTitle = item.title ? String(item.title) : path.basename(filename, path.extname(filename));
187
+ const rawDescription = item.description ? String(item.description) : '';
83
188
  const tags = Array.isArray(item.tags) ? item.tags.map(t => String(t).trim()).filter(Boolean) : [];
84
189
 
85
190
  const privacyStatus = item.privacyStatus && VALID_PRIVACY_STATUSES.has(item.privacyStatus)
@@ -88,6 +193,10 @@ export function loadMetadata(targetFolder, customMetadataPath) {
88
193
 
89
194
  const categoryId = item.categoryId ? String(item.categoryId) : defaultCategory;
90
195
 
196
+ // Apply template interpolation for known variables
197
+ const title = interpolateTemplates(rawTitle, templateVariables);
198
+ const description = interpolateTemplates(rawDescription, templateVariables);
199
+
91
200
  return {
92
201
  filename,
93
202
  filePath,
@@ -96,11 +205,22 @@ export function loadMetadata(targetFolder, customMetadataPath) {
96
205
  tags,
97
206
  categoryId,
98
207
  privacyStatus,
208
+ defaultLanguage: item.defaultLanguage || 'en',
209
+ defaultAudioLanguage: item.defaultAudioLanguage || 'en',
210
+ license: item.license || 'youtube',
211
+ embeddable: item.embeddable !== undefined ? item.embeddable : true,
212
+ publicStatsViewable: item.publicStatsViewable !== undefined ? item.publicStatsViewable : true,
213
+ selfDeclaredMadeForKids: item.selfDeclaredMadeForKids !== undefined ? item.selfDeclaredMadeForKids : false,
214
+ notifySubscribers: item.notifySubscribers !== undefined ? item.notifySubscribers : false,
215
+ trackNumber: item.trackNumber,
216
+ playlistPosition: item.playlistPosition,
99
217
  };
100
218
  });
101
219
 
102
220
  return {
103
221
  playlistId,
222
+ playlist,
223
+ templateVariables,
104
224
  privacyStatus: defaultPrivacy,
105
225
  categoryId: defaultCategory,
106
226
  videos,
package/src/state.js CHANGED
@@ -63,6 +63,45 @@ export class UploadState {
63
63
  this.save();
64
64
  }
65
65
 
66
+ /**
67
+ * Check if a playlist has already been created.
68
+ * @param {string} [key='default']
69
+ * @returns {{ playlistId: string, url: string, createdAt: string } | null}
70
+ */
71
+ getPlaylist(key = 'default') {
72
+ if (this.data.playlists && this.data.playlists[key]) {
73
+ return this.data.playlists[key];
74
+ }
75
+ if (this.data.playlistId) {
76
+ return {
77
+ playlistId: this.data.playlistId,
78
+ url: `https://www.youtube.com/playlist?list=${this.data.playlistId}`,
79
+ createdAt: this.data.playlistCreatedAt || null,
80
+ };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Record a created playlist.
87
+ * @param {string} key
88
+ * @param {string} playlistId
89
+ * @param {string} [url]
90
+ */
91
+ recordPlaylistInfo(key, playlistId, url) {
92
+ if (!this.data.playlists) {
93
+ this.data.playlists = {};
94
+ }
95
+ const resolvedUrl = url || `https://www.youtube.com/playlist?list=${playlistId}`;
96
+ this.data.playlists[key] = {
97
+ playlistId,
98
+ url: resolvedUrl,
99
+ createdAt: new Date().toISOString(),
100
+ };
101
+ this.data.playlistId = playlistId;
102
+ this.save();
103
+ }
104
+
66
105
  /**
67
106
  * Record playlist association.
68
107
  * @param {string} filename