@x12i/youtube-video-uploader-cli 1.0.0 → 1.1.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
@@ -21,6 +21,9 @@ program
21
21
  .option('-m, --metadata <file>', 'Custom path to metadata.json file')
22
22
  .option('-f, --force', 'Force re-upload of already uploaded videos', false)
23
23
  .option('-n, --dry-run', 'Preview video uploads and estimate quota without uploading', false)
24
+ .option('-l, --limit <number>', 'Maximum number of videos to upload in this run (e.g. 5 or 6)')
25
+ .option('--safe-quota', 'Cap upload batch to stay safely within standard daily 10,000 units quota (default ~5-6 videos)', false)
26
+ .option('--max-quota <units>', 'Maximum allowed API quota units for this run')
24
27
  .option('--client-id <id>', 'Google OAuth Client ID')
25
28
  .option('--client-secret <secret>', 'Google OAuth Client Secret')
26
29
  .option('--client-secrets-file <file>', 'Path to client_secret.json downloaded from Google Cloud')
@@ -38,6 +41,12 @@ program
38
41
  if (options.metadata) {
39
42
  console.log(`${pc.bold('📄 Metadata File :')} ${pc.white(path.resolve(options.metadata))}`);
40
43
  }
44
+ if (options.limit) {
45
+ console.log(`${pc.bold('🎯 Batch Limit :')} ${pc.cyan(`${options.limit} videos max`)}`);
46
+ }
47
+ if (options.safeQuota) {
48
+ console.log(`${pc.bold('🛡️ Safe Quota Mode :')} ${pc.green('ENABLED (capped under daily 10,000 units)')}`);
49
+ }
41
50
  if (options.dryRun) {
42
51
  console.log(`${pc.yellow(pc.bold('🔍 Mode :'))} ${pc.yellow('DRY RUN (no uploads will be made)')}`);
43
52
  }
@@ -56,6 +65,9 @@ program
56
65
  metadataFile: options.metadata,
57
66
  force: options.force,
58
67
  dryRun: options.dryRun,
68
+ limit: options.limit ? parseInt(options.limit, 10) : undefined,
69
+ safeQuota: options.safeQuota,
70
+ maxQuota: options.maxQuota ? parseInt(options.maxQuota, 10) : undefined,
59
71
  tokenFile,
60
72
  clientSecretsFile: options.clientSecretsFile,
61
73
  clientId: options.clientId,
@@ -68,18 +80,21 @@ program
68
80
  pc.bold(`Found `) +
69
81
  pc.green(pc.bold(`${event.total}`)) +
70
82
  pc.bold(` item(s): `) +
71
- pc.cyan(`${event.toProcess.length} to upload`) +
83
+ pc.cyan(`${event.toProcess.length} to upload now`) +
84
+ (event.deferred?.length ? `, ` + pc.yellow(`${event.deferred.length} deferred`) : '') +
72
85
  `, ` +
73
86
  pc.dim(`${event.skipped.length} skipped`)
74
87
  );
75
88
 
76
89
  if (event.playlistId) {
77
- console.log(pc.dim(`Playlist ID: ${event.playlistId}`));
90
+ console.log(pc.dim(`Playlist ID : ${event.playlistId}`));
91
+ } else if (event.needsPlaylistCreation) {
92
+ console.log(pc.cyan(`Playlist Creation : "${event.playlist.snippet?.title}"`));
78
93
  }
79
94
 
80
95
  console.log(
81
96
  pc.bold(`Estimated API Quota: `) +
82
- (event.estimatedQuota > DAILY_DEFAULT_QUOTA ? pc.red(pc.bold(`${event.estimatedQuota} units`)) : pc.cyan(`${event.estimatedQuota} units`)) +
97
+ (event.estimatedQuota > DAILY_DEFAULT_QUOTA ? pc.red(pc.bold(`${event.estimatedQuota} units`)) : pc.green(pc.bold(`${event.estimatedQuota} units`))) +
83
98
  pc.dim(` / ${DAILY_DEFAULT_QUOTA} daily default units`)
84
99
  );
85
100
 
@@ -88,12 +103,27 @@ program
88
103
  }
89
104
  console.log();
90
105
 
106
+ if (event.deferred?.length > 0) {
107
+ for (const d of event.deferred) {
108
+ console.log(` ${pc.yellow('⏳ [DEFERRED]')} ${pc.dim(d.filename)} ${pc.dim(`(${d.reason})`)}`);
109
+ }
110
+ console.log();
111
+ }
112
+
91
113
  if (event.skipped.length > 0) {
92
114
  for (const s of event.skipped) {
93
- console.log(` ${pc.dim('⏭️ [SKIPPED]')} ${pc.dim(s.filename)} ${pc.dim(`(${s.reason})`)}`);
115
+ console.log(` ${pc.dim('⏭️ [SKIPPED] ')} ${pc.dim(s.filename)} ${pc.dim(`(${s.reason})`)}`);
94
116
  }
95
117
  console.log();
96
118
  }
119
+ } else if (event.type === 'authenticated_channel') {
120
+ console.log(pc.green(`👤 Authenticated Channel: `) + pc.bold(event.channel.title) + pc.dim(` (${event.channel.customUrl || event.channel.id})`));
121
+ console.log();
122
+ } else if (event.type === 'playlist_create_start') {
123
+ console.log(` ${pc.cyan('📑')} Creating playlist: ${pc.bold(event.playlist.snippet?.title)}...`);
124
+ } else if (event.type === 'playlist_create_success') {
125
+ console.log(pc.green(` ✅ Playlist created! URL: `) + pc.underline(pc.blue(event.url)));
126
+ console.log();
97
127
  } else if (event.type === 'upload_start') {
98
128
  process.stdout.write(` ${pc.cyan('⬆️ ')} [${event.index + 1}/${event.total}] Uploading ${pc.bold(event.item.filename)} ("${event.item.title}")... `);
99
129
  } else if (event.type === 'upload_success') {
@@ -124,6 +154,9 @@ program
124
154
  console.log(` • Total items : ${pc.bold(result.total)}`);
125
155
  console.log(` • Uploaded : ${pc.green(pc.bold(result.uploaded.length))}`);
126
156
  console.log(` • Skipped : ${pc.dim(result.skipped.length)}`);
157
+ if (result.deferred?.length > 0) {
158
+ console.log(` • Deferred : ${pc.yellow(pc.bold(result.deferred.length))} ${pc.dim('(run again tomorrow for the next batch)')}`);
159
+ }
127
160
  if (result.failed.length > 0) {
128
161
  console.log(` • Failed : ${pc.red(pc.bold(result.failed.length))}`);
129
162
  }
@@ -132,6 +165,10 @@ program
132
165
  if (result.failed.length > 0) {
133
166
  console.log(pc.red(`⚠️ Batch completed with ${result.failed.length} failure(s).`));
134
167
  process.exit(1);
168
+ } else if (result.deferred?.length > 0) {
169
+ console.log(pc.green(pc.bold(`🎉 Batch completed! ${result.uploaded.length} videos uploaded today.`)));
170
+ console.log(pc.cyan(`👉 Run the exact same command tomorrow to upload the remaining ${result.deferred.length} videos.`));
171
+ console.log();
135
172
  } else if (result.uploaded.length > 0) {
136
173
  console.log(pc.green(pc.bold(`🎉 All videos successfully uploaded to YouTube!`)));
137
174
  console.log();
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.1.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
@@ -14,12 +14,49 @@ export const YOUTUBE_SCOPES = [
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/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
package/src/uploader.js CHANGED
@@ -7,8 +7,36 @@ import { authenticate } from './auth.js';
7
7
 
8
8
  export const QUOTA_PER_VIDEO_UPLOAD = 1600;
9
9
  export const QUOTA_PER_PLAYLIST_ITEM = 50;
10
+ export const QUOTA_PER_PLAYLIST_CREATE = 50;
10
11
  export const DAILY_DEFAULT_QUOTA = 10000;
11
12
 
13
+ import { interpolateTemplates } from './metadata.js';
14
+
15
+ /**
16
+ * Create a YouTube Playlist.
17
+ *
18
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
19
+ * @param {object} snippet
20
+ * @param {object} [status={ privacyStatus: 'private' }]
21
+ * @returns {Promise<{ playlistId: string, url: string, data: any }>}
22
+ */
23
+ export async function createPlaylist(youtube, snippet, status = { privacyStatus: 'private' }) {
24
+ const res = await youtube.playlists.insert({
25
+ part: ['snippet', 'status'],
26
+ requestBody: {
27
+ snippet,
28
+ status,
29
+ },
30
+ });
31
+
32
+ const playlistId = res.data.id;
33
+ return {
34
+ playlistId,
35
+ url: `https://www.youtube.com/playlist?list=${playlistId}`,
36
+ data: res.data,
37
+ };
38
+ }
39
+
12
40
  /**
13
41
  * Upload a single video file to YouTube via YouTube Data API v3.
14
42
  *
@@ -29,23 +57,37 @@ export async function uploadVideo(youtube, filePath, meta, defaultPrivacy = 'pri
29
57
  throw new Error(`Video file is empty (0 bytes): ${filePath}`);
30
58
  }
31
59
 
32
- const res = await youtube.videos.insert({
60
+ const snippet = {
61
+ title: meta.title,
62
+ description: meta.description || '',
63
+ tags: meta.tags || [],
64
+ categoryId: String(meta.categoryId || defaultCategory || '10'),
65
+ };
66
+
67
+ if (meta.defaultLanguage) snippet.defaultLanguage = meta.defaultLanguage;
68
+ if (meta.defaultAudioLanguage) snippet.defaultAudioLanguage = meta.defaultAudioLanguage;
69
+
70
+ const status = {
71
+ privacyStatus: meta.privacyStatus || defaultPrivacy || 'private',
72
+ license: meta.license || 'youtube',
73
+ embeddable: meta.embeddable !== undefined ? meta.embeddable : true,
74
+ publicStatsViewable: meta.publicStatsViewable !== undefined ? meta.publicStatsViewable : true,
75
+ selfDeclaredMadeForKids: meta.selfDeclaredMadeForKids !== undefined ? meta.selfDeclaredMadeForKids : false,
76
+ };
77
+
78
+ const insertParams = {
33
79
  part: ['snippet', 'status'],
80
+ notifySubscribers: meta.notifySubscribers !== undefined ? meta.notifySubscribers : false,
34
81
  requestBody: {
35
- snippet: {
36
- title: meta.title,
37
- description: meta.description || '',
38
- tags: meta.tags || [],
39
- categoryId: meta.categoryId || defaultCategory || '10',
40
- },
41
- status: {
42
- privacyStatus: meta.privacyStatus || defaultPrivacy || 'private',
43
- },
82
+ snippet,
83
+ status,
44
84
  },
45
85
  media: {
46
86
  body: fs.createReadStream(filePath),
47
87
  },
48
- });
88
+ };
89
+
90
+ const res = await youtube.videos.insert(insertParams);
49
91
 
50
92
  const videoId = res.data.id;
51
93
  return {
@@ -60,19 +102,25 @@ export async function uploadVideo(youtube, filePath, meta, defaultPrivacy = 'pri
60
102
  * @param {import('googleapis').youtube_v3.Youtube} youtube
61
103
  * @param {string} playlistId
62
104
  * @param {string} videoId
105
+ * @param {number} [position]
63
106
  * @returns {Promise<any>}
64
107
  */
65
- export async function addToPlaylist(youtube, playlistId, videoId) {
108
+ export async function addToPlaylist(youtube, playlistId, videoId, position) {
109
+ const snippet = {
110
+ playlistId,
111
+ resourceId: {
112
+ kind: 'youtube#video',
113
+ videoId,
114
+ },
115
+ };
116
+ if (typeof position === 'number') {
117
+ snippet.position = position;
118
+ }
119
+
66
120
  const res = await youtube.playlistItems.insert({
67
121
  part: ['snippet'],
68
122
  requestBody: {
69
- snippet: {
70
- playlistId,
71
- resourceId: {
72
- kind: 'youtube#video',
73
- videoId,
74
- },
75
- },
123
+ snippet,
76
124
  },
77
125
  });
78
126
  return res.data;
@@ -95,7 +143,8 @@ export async function addToPlaylist(youtube, playlistId, videoId) {
95
143
  * uploaded: Array<{ filename: string, videoId: string, url: string, addedToPlaylist: boolean }>,
96
144
  * skipped: Array<{ filename: string, videoId?: string, reason: string }>,
97
145
  * failed: Array<{ filename: string, error: Error }>,
98
- * estimatedQuota: number
146
+ * estimatedQuota: number,
147
+ * playlistId?: string | null
99
148
  * }>}
100
149
  */
101
150
  export async function batchUpload(targetFolder, options = {}) {
@@ -107,7 +156,11 @@ export async function batchUpload(targetFolder, options = {}) {
107
156
  const config = loadMetadata(resolvedFolder, options.metadataFile);
108
157
  const state = new UploadState(resolvedFolder);
109
158
 
110
- const playlistId = config.playlistId;
159
+ const playlistKey = config.playlist?.key || 'default';
160
+ const cachedPlaylist = state.getPlaylist(playlistKey);
161
+ let playlistId = config.playlistId || cachedPlaylist?.playlistId || null;
162
+ const needsPlaylistCreation = !playlistId && Boolean(config.playlist?.snippet);
163
+
111
164
  const toProcess = [];
112
165
  const skipped = [];
113
166
 
@@ -132,16 +185,53 @@ export async function batchUpload(targetFolder, options = {}) {
132
185
  }
133
186
  }
134
187
 
135
- const estimatedQuota = (toProcess.length * QUOTA_PER_VIDEO_UPLOAD) +
136
- (playlistId ? toProcess.length * QUOTA_PER_PLAYLIST_ITEM : 0);
188
+ const willHavePlaylist = Boolean(playlistId || needsPlaylistCreation);
189
+ const baseQuota = needsPlaylistCreation ? QUOTA_PER_PLAYLIST_CREATE : 0;
190
+ const costPerVideo = QUOTA_PER_VIDEO_UPLOAD + (willHavePlaylist ? QUOTA_PER_PLAYLIST_ITEM : 0);
191
+
192
+ let limit = Infinity;
193
+ if (options.limit !== undefined && options.limit !== null && !isNaN(options.limit)) {
194
+ limit = Math.max(0, parseInt(options.limit, 10));
195
+ }
196
+
197
+ let maxQuota = Infinity;
198
+ if (options.maxQuota !== undefined && options.maxQuota !== null && !isNaN(options.maxQuota)) {
199
+ maxQuota = parseInt(options.maxQuota, 10);
200
+ } else if (options.safeQuota) {
201
+ maxQuota = DAILY_DEFAULT_QUOTA - 50; // 9,950 units safe limit
202
+ }
203
+
204
+ const activeToProcess = [];
205
+ const deferred = [];
206
+
207
+ for (const item of toProcess) {
208
+ const candidateQuota = baseQuota + ((activeToProcess.length + 1) * costPerVideo);
209
+ if (activeToProcess.length < limit && candidateQuota <= maxQuota) {
210
+ activeToProcess.push(item);
211
+ } else {
212
+ deferred.push({
213
+ filename: item.filename,
214
+ reason: activeToProcess.length >= limit
215
+ ? `Deferred by upload limit (${limit})`
216
+ : `Deferred for next batch to stay under quota limit (${maxQuota} units)`,
217
+ });
218
+ }
219
+ }
220
+
221
+ const estimatedQuota = baseQuota + (activeToProcess.length * costPerVideo);
137
222
 
138
223
  onEvent({
139
224
  type: 'plan',
140
225
  total: config.videos.length,
141
- toProcess,
226
+ toProcess: activeToProcess,
142
227
  skipped,
228
+ deferred,
143
229
  estimatedQuota,
144
230
  playlistId,
231
+ needsPlaylistCreation,
232
+ playlist: config.playlist,
233
+ safeQuota: Boolean(options.safeQuota),
234
+ limit: isFinite(limit) ? limit : null,
145
235
  });
146
236
 
147
237
  if (dryRun) {
@@ -150,19 +240,23 @@ export async function batchUpload(targetFolder, options = {}) {
150
240
  total: config.videos.length,
151
241
  uploaded: [],
152
242
  skipped,
243
+ deferred,
153
244
  failed: [],
154
245
  estimatedQuota,
246
+ playlistId,
155
247
  };
156
248
  }
157
249
 
158
- if (toProcess.length === 0) {
250
+ if (activeToProcess.length === 0 && !needsPlaylistCreation) {
159
251
  return {
160
252
  targetFolder: resolvedFolder,
161
253
  total: config.videos.length,
162
254
  uploaded: [],
163
255
  skipped,
256
+ deferred,
164
257
  failed: [],
165
258
  estimatedQuota: 0,
259
+ playlistId,
166
260
  };
167
261
  }
168
262
 
@@ -176,12 +270,61 @@ export async function batchUpload(targetFolder, options = {}) {
176
270
 
177
271
  const youtube = google.youtube({ version: 'v3', auth });
178
272
 
273
+ // Verify and display authenticated channel identity
274
+ try {
275
+ const channelRes = await youtube.channels.list({
276
+ part: ['snippet'],
277
+ mine: true,
278
+ });
279
+ if (channelRes.data.items && channelRes.data.items.length > 0) {
280
+ const ch = channelRes.data.items[0];
281
+ const channelInfo = {
282
+ id: ch.id,
283
+ title: ch.snippet?.title || 'Unknown',
284
+ customUrl: ch.snippet?.customUrl || null,
285
+ };
286
+ onEvent({ type: 'authenticated_channel', channel: channelInfo });
287
+ }
288
+ } catch {
289
+ // Non-fatal
290
+ }
291
+
292
+ // Create playlist if needed
293
+ if (needsPlaylistCreation && config.playlist?.snippet) {
294
+ onEvent({ type: 'playlist_create_start', playlist: config.playlist });
295
+ try {
296
+ const created = await createPlaylist(
297
+ youtube,
298
+ config.playlist.snippet,
299
+ config.playlist.status || { privacyStatus: config.privacyStatus }
300
+ );
301
+ playlistId = created.playlistId;
302
+ state.recordPlaylistInfo(playlistKey, created.playlistId, created.url);
303
+ onEvent({ type: 'playlist_create_success', playlistId, url: created.url });
304
+ } catch (err) {
305
+ onEvent({ type: 'playlist_create_error', error: err });
306
+ throw new Error(`Failed to create YouTube Playlist: ${err.message}`);
307
+ }
308
+ }
309
+
310
+ // Apply playlistUrl template interpolation to pending video items
311
+ const playlistUrl = playlistId ? `https://www.youtube.com/playlist?list=${playlistId}` : '';
312
+ const finalTemplateVars = {
313
+ ...config.templateVariables,
314
+ playlistUrl: playlistUrl || config.templateVariables.playlistUrl || '',
315
+ };
316
+
317
+ for (const item of activeToProcess) {
318
+ item.title = interpolateTemplates(item.title, finalTemplateVars);
319
+ item.description = interpolateTemplates(item.description, finalTemplateVars);
320
+ }
321
+
179
322
  const uploaded = [];
180
323
  const failed = [];
181
324
 
182
- for (let i = 0; i < toProcess.length; i++) {
183
- const item = toProcess[i];
184
- onEvent({ type: 'upload_start', item, index: i, total: toProcess.length });
325
+ for (let i = 0; i < activeToProcess.length; i++) {
326
+ const item = activeToProcess[i];
327
+ onEvent({ type: 'upload_start', item, index: i, total: activeToProcess.length });
185
328
 
186
329
  try {
187
330
  const uploadRes = await uploadVideo(
@@ -196,7 +339,7 @@ export async function batchUpload(targetFolder, options = {}) {
196
339
  if (playlistId) {
197
340
  onEvent({ type: 'playlist_start', item, videoId: uploadRes.videoId, playlistId });
198
341
  try {
199
- await addToPlaylist(youtube, playlistId, uploadRes.videoId);
342
+ await addToPlaylist(youtube, playlistId, uploadRes.videoId, item.playlistPosition);
200
343
  addedToPlaylist = true;
201
344
  onEvent({ type: 'playlist_success', item, videoId: uploadRes.videoId, playlistId });
202
345
  } catch (playlistErr) {
@@ -228,5 +371,6 @@ export async function batchUpload(targetFolder, options = {}) {
228
371
  skipped,
229
372
  failed,
230
373
  estimatedQuota,
374
+ playlistId,
231
375
  };
232
376
  }