@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 +41 -4
- package/package.json +1 -1
- package/src/auth.js +37 -0
- package/src/metadata.js +128 -8
- package/src/state.js +39 -0
- package/src/uploader.js +173 -29
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.
|
|
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.
|
|
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
|
-
:
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
82
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
136
|
-
|
|
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 (
|
|
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 <
|
|
183
|
-
const item =
|
|
184
|
-
onEvent({ type: 'upload_start', item, index: i, total:
|
|
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
|
}
|