@x12i/youtube-video-uploader-cli 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # @x12i/youtube-video-uploader-cli 📺
2
+
3
+ > Automated batch video uploader for YouTube Data API v3 with OAuth2 authentication, playlist assignment, quota estimation, and smart upload-state resume.
4
+
5
+ ---
6
+
7
+ ## 🚀 Features
8
+
9
+ - **🔐 Interactive OAuth2 Authentication**: Authenticates with YouTube using Google OAuth2. Features a local loopback server to automatically capture the callback redirect, with a terminal prompt fallback.
10
+ - **💾 Token Persistence**: Caches tokens locally in `token.json` so you only need to authorize once. Automatically handles token refresh.
11
+ - **📄 Metadata-Driven Batch Uploads**: Configure titles, descriptions, tags, category, privacy, and playlist via a simple `metadata.json` file.
12
+ - **⚡ Smart Skip & Resume**: Saves upload status to `.upload-history.json` and skips previously uploaded files on rerun to protect your API quota.
13
+ - **📑 Playlist Integration**: Automatically adds uploaded videos to a specified YouTube Playlist (`playlistId`).
14
+ - **📊 Quota Estimation**: Calculates estimated YouTube Data API units (1,600 units/video, 50 units/playlist addition) and warns if approaching the standard 10,000 units/day limit.
15
+ - **🔍 Dry Run Mode**: Validate your `metadata.json`, inspect files, and check estimated quota before uploading.
16
+
17
+ ---
18
+
19
+ ## 🔑 Google Cloud Setup (Prerequisite)
20
+
21
+ Before using the uploader, you need OAuth2 credentials from Google Cloud:
22
+
23
+ 1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
24
+ 2. Create a new project and enable the **YouTube Data API v3**.
25
+ 3. Under **OAuth consent screen**:
26
+ - Set user type to **External**.
27
+ - Add your Google account email under **Test Users**.
28
+ 4. Under **APIs & Services > Credentials**:
29
+ - Click **Create Credentials > OAuth client ID**.
30
+ - Application type: **Desktop App** (or Web Application with redirect URI `http://localhost:3000/oauth2callback`).
31
+ - Download the `client_secret.json` or copy your `Client ID` and `Client Secret`.
32
+
33
+ ---
34
+
35
+ ## 📦 Installation & Quick Start
36
+
37
+ ### Run immediately via `npx`
38
+
39
+ ```bash
40
+ # In the folder containing your .mp4 files and metadata.json:
41
+ npx @x12i/youtube-video-uploader-cli
42
+ ```
43
+
44
+ ### Install globally
45
+
46
+ ```bash
47
+ npm install -g @x12i/youtube-video-uploader-cli
48
+
49
+ # Now you can use any of these commands anywhere:
50
+ youtube-video-uploader [folder]
51
+ yt-uploader [folder]
52
+ yvu-cli [folder]
53
+ ```
54
+
55
+ ---
56
+
57
+ ## 📂 Folder Structure & `metadata.json`
58
+
59
+ Place your videos and a `metadata.json` file in your target folder:
60
+
61
+ ```text
62
+ my-videos/
63
+ ├── metadata.json
64
+ ├── track1.mp4
65
+ ├── track2.mp4
66
+ └── track3.mp4
67
+ ```
68
+
69
+ ### `metadata.json` Schema
70
+
71
+ ```json
72
+ {
73
+ "playlistId": "PLxXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
74
+ "privacyStatus": "private",
75
+ "categoryId": "10",
76
+ "videos": [
77
+ {
78
+ "filename": "track1.mp4",
79
+ "title": "Ambient Track 01 - Chill Beats",
80
+ "description": "Full track audio with custom artwork.\n\nSubscribe for more!",
81
+ "tags": ["ambient", "lofi", "chill"]
82
+ },
83
+ {
84
+ "filename": "track2.mp4",
85
+ "title": "Ambient Track 02 - Night Drives",
86
+ "description": "Relaxing night vibes.",
87
+ "tags": ["night", "vibes"]
88
+ }
89
+ ]
90
+ }
91
+ ```
92
+
93
+ #### Category IDs:
94
+ - `"10"` = Music (default)
95
+ - `"22"` = People & Blogs
96
+ - `"27"` = Education
97
+ - `"28"` = Science & Technology
98
+
99
+ ---
100
+
101
+ ## ⚙️ CLI Options Reference
102
+
103
+ ```text
104
+ Usage: youtube-video-uploader [directory] [options]
105
+
106
+ Arguments:
107
+ directory Target folder containing MP4 videos and metadata.json (default: ".")
108
+
109
+ Options:
110
+ -v, --version Output current version
111
+ -d, --dir <path> Explicit target directory path
112
+ -m, --metadata <file> Custom path to metadata.json file
113
+ -f, --force Force re-upload of already uploaded videos (default: false)
114
+ -n, --dry-run Preview video uploads and estimate quota without uploading (default: false)
115
+ --client-id <id> Google OAuth Client ID
116
+ --client-secret <secret> Google OAuth Client Secret
117
+ --client-secrets-file <file> Path to client_secret.json downloaded from Google Cloud
118
+ --token-file <file> Path to store or read OAuth token (default: token.json in target dir)
119
+ -q, --quiet Minimal output mode (default: false)
120
+ -h, --help Display help
121
+ ```
122
+
123
+ ---
124
+
125
+ ## 💡 Examples
126
+
127
+ ### 1. Dry run (verify metadata and estimate API quota)
128
+ ```bash
129
+ npx @x12i/youtube-video-uploader-cli ./my-videos --dry-run
130
+ ```
131
+
132
+ ### 2. Upload with credentials from environment variables
133
+ ```bash
134
+ export YOUTUBE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
135
+ export YOUTUBE_CLIENT_SECRET="your-client-secret"
136
+
137
+ npx @x12i/youtube-video-uploader-cli ./my-videos
138
+ ```
139
+
140
+ ### 3. Upload with downloaded `client_secret.json`
141
+ ```bash
142
+ npx @x12i/youtube-video-uploader-cli ./my-videos --client-secrets-file ./client_secret.json
143
+ ```
144
+
145
+ ---
146
+
147
+ ## 🛠️ Programmatic Node.js API
148
+
149
+ ```javascript
150
+ import { batchUpload, uploadVideo, loadMetadata } from '@x12i/youtube-video-uploader-cli';
151
+
152
+ const result = await batchUpload('./my-videos', {
153
+ clientId: process.env.YOUTUBE_CLIENT_ID,
154
+ clientSecret: process.env.YOUTUBE_CLIENT_SECRET,
155
+ onEvent: (event) => {
156
+ if (event.type === 'upload_success') {
157
+ console.log(`Uploaded ${event.item.filename} -> ${event.record.url}`);
158
+ }
159
+ },
160
+ });
161
+
162
+ console.log(`Uploaded: ${result.uploaded.length}, Skipped: ${result.skipped.length}`);
163
+ ```
164
+
165
+ ---
166
+
167
+ ## 📜 License
168
+
169
+ MIT License.
package/bin/cli.js ADDED
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import path from 'node:path';
5
+ import fs from 'node:fs';
6
+ import pc from 'picocolors';
7
+ import { batchUpload, DAILY_DEFAULT_QUOTA } from '../src/index.js';
8
+
9
+ // Read package.json for version
10
+ const pkgPath = new URL('../package.json', import.meta.url);
11
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
12
+
13
+ const program = new Command();
14
+
15
+ program
16
+ .name('youtube-video-uploader')
17
+ .description('Batch upload MP4 videos to YouTube via YouTube Data API v3 with OAuth2 authentication and metadata.json')
18
+ .version(pkg.version, '-v, --version', 'Output current version')
19
+ .argument('[directory]', 'Target folder containing MP4 videos and metadata.json (default: current directory)', '.')
20
+ .option('-d, --dir <path>', 'Explicit target directory path')
21
+ .option('-m, --metadata <file>', 'Custom path to metadata.json file')
22
+ .option('-f, --force', 'Force re-upload of already uploaded videos', false)
23
+ .option('-n, --dry-run', 'Preview video uploads and estimate quota without uploading', false)
24
+ .option('--client-id <id>', 'Google OAuth Client ID')
25
+ .option('--client-secret <secret>', 'Google OAuth Client Secret')
26
+ .option('--client-secrets-file <file>', 'Path to client_secret.json downloaded from Google Cloud')
27
+ .option('--token-file <file>', 'Path to store or read OAuth token (default: token.json in target dir)')
28
+ .option('-q, --quiet', 'Minimal output mode', false)
29
+ .action(async (directoryArg, options) => {
30
+ const targetDir = path.resolve(options.dir || directoryArg || process.cwd());
31
+ const isQuiet = options.quiet;
32
+
33
+ if (!isQuiet) {
34
+ console.log();
35
+ console.log(pc.bold(pc.red(`📺 YouTube Video Uploader CLI`)) + ` ${pc.dim(`v${pkg.version}`)}`);
36
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
37
+ console.log(`${pc.bold('📂 Target Folder :')} ${pc.white(targetDir)}`);
38
+ if (options.metadata) {
39
+ console.log(`${pc.bold('📄 Metadata File :')} ${pc.white(path.resolve(options.metadata))}`);
40
+ }
41
+ if (options.dryRun) {
42
+ console.log(`${pc.yellow(pc.bold('🔍 Mode :'))} ${pc.yellow('DRY RUN (no uploads will be made)')}`);
43
+ }
44
+ if (options.force) {
45
+ console.log(`${pc.magenta(pc.bold('⚡ Force :'))} ${pc.magenta('ENABLED (re-uploading previously uploaded videos)')}`);
46
+ }
47
+ console.log();
48
+ }
49
+
50
+ try {
51
+ const tokenFile = options.tokenFile
52
+ ? path.resolve(options.tokenFile)
53
+ : path.join(targetDir, 'token.json');
54
+
55
+ const result = await batchUpload(targetDir, {
56
+ metadataFile: options.metadata,
57
+ force: options.force,
58
+ dryRun: options.dryRun,
59
+ tokenFile,
60
+ clientSecretsFile: options.clientSecretsFile,
61
+ clientId: options.clientId,
62
+ clientSecret: options.clientSecret,
63
+ onEvent: (event) => {
64
+ if (isQuiet) return;
65
+
66
+ if (event.type === 'plan') {
67
+ console.log(
68
+ pc.bold(`Found `) +
69
+ pc.green(pc.bold(`${event.total}`)) +
70
+ pc.bold(` item(s): `) +
71
+ pc.cyan(`${event.toProcess.length} to upload`) +
72
+ `, ` +
73
+ pc.dim(`${event.skipped.length} skipped`)
74
+ );
75
+
76
+ if (event.playlistId) {
77
+ console.log(pc.dim(`Playlist ID: ${event.playlistId}`));
78
+ }
79
+
80
+ console.log(
81
+ pc.bold(`Estimated API Quota: `) +
82
+ (event.estimatedQuota > DAILY_DEFAULT_QUOTA ? pc.red(pc.bold(`${event.estimatedQuota} units`)) : pc.cyan(`${event.estimatedQuota} units`)) +
83
+ pc.dim(` / ${DAILY_DEFAULT_QUOTA} daily default units`)
84
+ );
85
+
86
+ if (event.estimatedQuota > DAILY_DEFAULT_QUOTA) {
87
+ console.log(pc.yellow(`⚠️ Warning: Estimated quota exceeds standard daily 10,000 units quota limit!`));
88
+ }
89
+ console.log();
90
+
91
+ if (event.skipped.length > 0) {
92
+ for (const s of event.skipped) {
93
+ console.log(` ${pc.dim('⏭️ [SKIPPED]')} ${pc.dim(s.filename)} ${pc.dim(`(${s.reason})`)}`);
94
+ }
95
+ console.log();
96
+ }
97
+ } else if (event.type === 'upload_start') {
98
+ process.stdout.write(` ${pc.cyan('⬆️ ')} [${event.index + 1}/${event.total}] Uploading ${pc.bold(event.item.filename)} ("${event.item.title}")... `);
99
+ } else if (event.type === 'upload_success') {
100
+ console.log(pc.green(`UPLOADED!`));
101
+ console.log(` ${pc.dim('↳ Video URL:')} ${pc.underline(pc.blue(event.record.url))}`);
102
+ if (event.record.addedToPlaylist) {
103
+ console.log(` ${pc.dim('↳ Added to Playlist!')}`);
104
+ }
105
+ console.log();
106
+ } else if (event.type === 'upload_error') {
107
+ console.log(pc.red(`FAILED`));
108
+ console.error(pc.red(` Error: ${event.error.message}\n`));
109
+ } else if (event.type === 'playlist_error') {
110
+ console.warn(pc.yellow(` ⚠️ Playlist Error: ${event.error.message}`));
111
+ }
112
+ },
113
+ });
114
+
115
+ if (!isQuiet) {
116
+ if (options.dryRun) {
117
+ console.log(pc.yellow(`✨ Dry run complete. No uploads were performed.`));
118
+ console.log();
119
+ process.exit(0);
120
+ }
121
+
122
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
123
+ console.log(pc.bold(`📊 Summary:`));
124
+ console.log(` • Total items : ${pc.bold(result.total)}`);
125
+ console.log(` • Uploaded : ${pc.green(pc.bold(result.uploaded.length))}`);
126
+ console.log(` • Skipped : ${pc.dim(result.skipped.length)}`);
127
+ if (result.failed.length > 0) {
128
+ console.log(` • Failed : ${pc.red(pc.bold(result.failed.length))}`);
129
+ }
130
+ console.log();
131
+
132
+ if (result.failed.length > 0) {
133
+ console.log(pc.red(`⚠️ Batch completed with ${result.failed.length} failure(s).`));
134
+ process.exit(1);
135
+ } else if (result.uploaded.length > 0) {
136
+ console.log(pc.green(pc.bold(`🎉 All videos successfully uploaded to YouTube!`)));
137
+ console.log();
138
+ } else {
139
+ console.log(pc.green(`✅ All videos are up to date. Nothing to upload.`));
140
+ console.log();
141
+ }
142
+ }
143
+ } catch (err) {
144
+ console.error(pc.red(`\n❌ Error: ${err.message}\n`));
145
+ process.exit(1);
146
+ }
147
+ });
148
+
149
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@x12i/youtube-video-uploader-cli",
3
+ "version": "1.0.0",
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
+ "main": "src/index.js",
6
+ "types": "src/index.d.ts",
7
+ "type": "module",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "bin": {
12
+ "youtube-video-uploader": "bin/cli.js",
13
+ "yt-uploader": "bin/cli.js",
14
+ "yvu-cli": "bin/cli.js"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./src/index.d.ts",
19
+ "default": "./src/index.js"
20
+ }
21
+ },
22
+ "engines": {
23
+ "node": ">=18.0.0"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "src",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "start": "node bin/cli.js",
33
+ "test": "node --test test/**/*.test.js"
34
+ },
35
+ "keywords": [
36
+ "youtube",
37
+ "uploader",
38
+ "youtube-data-api",
39
+ "oauth2",
40
+ "video-upload",
41
+ "playlist",
42
+ "cli"
43
+ ],
44
+ "author": "",
45
+ "license": "MIT",
46
+ "dependencies": {
47
+ "commander": "^13.1.0",
48
+ "googleapis": "^144.0.0",
49
+ "open": "^10.1.0",
50
+ "picocolors": "^1.1.1"
51
+ }
52
+ }
package/src/auth.js ADDED
@@ -0,0 +1,235 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import http from 'node:http';
4
+ import readline from 'node:readline';
5
+ import { google } from 'googleapis';
6
+ import open from 'open';
7
+ import pc from 'picocolors';
8
+
9
+ export const YOUTUBE_SCOPES = [
10
+ 'https://www.googleapis.com/auth/youtube.upload',
11
+ 'https://www.googleapis.com/auth/youtube',
12
+ ];
13
+
14
+ export const DEFAULT_REDIRECT_URI = 'http://localhost:3000/oauth2callback';
15
+ export const DEFAULT_TOKEN_FILENAME = 'token.json';
16
+
17
+ /**
18
+ * Extract Client ID and Client Secret from file or environment.
19
+ * @param {object} options
20
+ * @returns {{ clientId: string, clientSecret: string, redirectUri: string }}
21
+ */
22
+ export function resolveOAuthCredentials(options = {}) {
23
+ let clientId = options.clientId || process.env.YOUTUBE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID;
24
+ let clientSecret = options.clientSecret || process.env.YOUTUBE_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET;
25
+ const redirectUri = options.redirectUri || DEFAULT_REDIRECT_URI;
26
+
27
+ if (options.clientSecretsFile && fs.existsSync(options.clientSecretsFile)) {
28
+ try {
29
+ const fileData = JSON.parse(fs.readFileSync(options.clientSecretsFile, 'utf-8'));
30
+ const credentials = fileData.installed || fileData.web || fileData;
31
+ clientId = credentials.client_id || clientId;
32
+ clientSecret = credentials.client_secret || clientSecret;
33
+ } catch (err) {
34
+ throw new Error(`Failed to parse client secrets file: ${err.message}`);
35
+ }
36
+ }
37
+
38
+ // Also check default client_secret.json in current directory or target folder
39
+ if (!clientId || !clientSecret) {
40
+ const localSecrets = ['client_secret.json', 'client_secrets.json', 'credentials.json'];
41
+ for (const name of localSecrets) {
42
+ const checkPath = path.resolve(name);
43
+ if (fs.existsSync(checkPath)) {
44
+ try {
45
+ const fileData = JSON.parse(fs.readFileSync(checkPath, 'utf-8'));
46
+ const credentials = fileData.installed || fileData.web || fileData;
47
+ if (credentials.client_id && credentials.client_secret) {
48
+ clientId = credentials.client_id;
49
+ clientSecret = credentials.client_secret;
50
+ break;
51
+ }
52
+ } catch {
53
+ // Ignore
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ if (!clientId || !clientSecret) {
60
+ throw new Error(
61
+ `Missing Google OAuth2 Credentials!\n` +
62
+ `Please provide them via:\n` +
63
+ ` • Environment variables: YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET\n` +
64
+ ` • CLI flags: --client-id <id> --client-secret <secret>\n` +
65
+ ` • File: --client-secrets-file <path/to/client_secret.json>`
66
+ );
67
+ }
68
+
69
+ return { clientId, clientSecret, redirectUri };
70
+ }
71
+
72
+ /**
73
+ * Authenticate with YouTube Data API using OAuth2 and cached tokens.
74
+ *
75
+ * @param {object} [options]
76
+ * @param {string} [options.clientId]
77
+ * @param {string} [options.clientSecret]
78
+ * @param {string} [options.clientSecretsFile]
79
+ * @param {string} [options.tokenFile]
80
+ * @param {string} [options.redirectUri]
81
+ * @param {boolean} [options.noBrowser=false]
82
+ * @returns {Promise<InstanceType<typeof google.auth.OAuth2>>}
83
+ */
84
+ export async function authenticate(options = {}) {
85
+ const { clientId, clientSecret, redirectUri } = resolveOAuthCredentials(options);
86
+ const tokenPath = path.resolve(options.tokenFile || DEFAULT_TOKEN_FILENAME);
87
+
88
+ const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri);
89
+
90
+ // Automatically save refreshed tokens
91
+ oauth2Client.on('tokens', (tokens) => {
92
+ try {
93
+ let currentTokens = {};
94
+ if (fs.existsSync(tokenPath)) {
95
+ currentTokens = JSON.parse(fs.readFileSync(tokenPath, 'utf-8'));
96
+ }
97
+ const updated = { ...currentTokens, ...tokens };
98
+ fs.writeFileSync(tokenPath, JSON.stringify(updated, null, 2), 'utf-8');
99
+ } catch (err) {
100
+ console.warn(pc.yellow(`Warning: Could not save refreshed token to ${tokenPath}: ${err.message}`));
101
+ }
102
+ });
103
+
104
+ // 1. Check if token already exists locally
105
+ if (fs.existsSync(tokenPath)) {
106
+ try {
107
+ const tokenContent = fs.readFileSync(tokenPath, 'utf-8');
108
+ const tokens = JSON.parse(tokenContent);
109
+ oauth2Client.setCredentials(tokens);
110
+ return oauth2Client;
111
+ } catch (err) {
112
+ console.warn(pc.yellow(`Warning: Existing token file is invalid (${err.message}). Re-authenticating...`));
113
+ }
114
+ }
115
+
116
+ // 2. Perform interactive authorization flow
117
+ const authUrl = oauth2Client.generateAuthUrl({
118
+ access_type: 'offline',
119
+ prompt: 'consent',
120
+ scope: YOUTUBE_SCOPES,
121
+ });
122
+
123
+ console.log();
124
+ console.log(pc.bold(pc.cyan(`🔐 YouTube OAuth2 Authorization Required`)));
125
+ console.log(pc.dim(`─────────────────────────────────────────────────`));
126
+ console.log(`Please visit the following URL to authorize access to your YouTube channel:\n`);
127
+ console.log(pc.underline(pc.blue(authUrl)));
128
+ console.log();
129
+
130
+ if (!options.noBrowser) {
131
+ try {
132
+ await open(authUrl);
133
+ console.log(pc.dim(`(Opened browser automatically. If it didn't open, copy the URL above.)`));
134
+ } catch {
135
+ // Ignore if browser launch failed
136
+ }
137
+ }
138
+
139
+ const code = await waitForAuthCode(redirectUri);
140
+
141
+ const { tokens } = await oauth2Client.getToken(code.trim());
142
+ oauth2Client.setCredentials(tokens);
143
+
144
+ try {
145
+ fs.writeFileSync(tokenPath, JSON.stringify(tokens, null, 2), 'utf-8');
146
+ console.log(pc.green(`✅ Authentication successful! Tokens saved to: ${tokenPath}`));
147
+ } catch (err) {
148
+ console.warn(pc.yellow(`Warning: Could not write token to ${tokenPath}: ${err.message}`));
149
+ }
150
+
151
+ return oauth2Client;
152
+ }
153
+
154
+ /**
155
+ * Capture auth code via lightweight local server with terminal prompt fallback.
156
+ * @param {string} redirectUri
157
+ * @returns {Promise<string>}
158
+ */
159
+ function waitForAuthCode(redirectUri) {
160
+ return new Promise((resolve) => {
161
+ let server = null;
162
+ let resolved = false;
163
+
164
+ const cleanup = () => {
165
+ if (server) {
166
+ try { server.close(); } catch {}
167
+ server = null;
168
+ }
169
+ };
170
+
171
+ try {
172
+ const parsed = new URL(redirectUri);
173
+ const port = parseInt(parsed.port, 10) || 3000;
174
+ const pathname = parsed.pathname || '/oauth2callback';
175
+
176
+ server = http.createServer((req, res) => {
177
+ try {
178
+ const reqUrl = new URL(req.url, `http://localhost:${port}`);
179
+ if (reqUrl.pathname === pathname) {
180
+ const code = reqUrl.searchParams.get('code');
181
+ const error = reqUrl.searchParams.get('error');
182
+
183
+ if (code) {
184
+ res.writeHead(200, { 'Content-Type': 'text/html' });
185
+ res.end(`
186
+ <html>
187
+ <body style="font-family: system-ui, sans-serif; text-align: center; padding: 50px;">
188
+ <h1 style="color: #2e7d32;">Authorization Successful!</h1>
189
+ <p>You can close this tab and return to the terminal.</p>
190
+ </body>
191
+ </html>
192
+ `);
193
+ if (!resolved) {
194
+ resolved = true;
195
+ cleanup();
196
+ resolve(code);
197
+ }
198
+ return;
199
+ } else if (error) {
200
+ res.writeHead(400, { 'Content-Type': 'text/html' });
201
+ res.end(`<h1>Authorization Error: ${error}</h1>`);
202
+ }
203
+ }
204
+ } catch {
205
+ // Ignore
206
+ }
207
+ });
208
+
209
+ server.listen(port, () => {
210
+ // Listening on localhost for callback
211
+ });
212
+
213
+ server.on('error', () => {
214
+ cleanup();
215
+ });
216
+ } catch {
217
+ cleanup();
218
+ }
219
+
220
+ // Terminal prompt fallback
221
+ const rl = readline.createInterface({
222
+ input: process.stdin,
223
+ output: process.stdout,
224
+ });
225
+
226
+ rl.question(pc.bold('\nEnter the authorization code (or URL query code): '), (inputCode) => {
227
+ rl.close();
228
+ if (!resolved) {
229
+ resolved = true;
230
+ cleanup();
231
+ resolve(inputCode);
232
+ }
233
+ });
234
+ });
235
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type { OAuth2Client } from 'google-auth-library';
2
+ import type { youtube_v3 } from 'googleapis';
3
+
4
+ export interface VideoMetadata {
5
+ filename: string;
6
+ title: string;
7
+ description: string;
8
+ tags: string[];
9
+ categoryId?: string;
10
+ privacyStatus?: 'private' | 'unlisted' | 'public';
11
+ filePath: string;
12
+ }
13
+
14
+ export interface MetadataConfig {
15
+ playlistId: string | null;
16
+ privacyStatus: 'private' | 'unlisted' | 'public';
17
+ categoryId: string;
18
+ videos: VideoMetadata[];
19
+ }
20
+
21
+ export interface BatchUploadOptions {
22
+ metadataFile?: string;
23
+ force?: boolean;
24
+ dryRun?: boolean;
25
+ tokenFile?: string;
26
+ clientSecretsFile?: string;
27
+ clientId?: string;
28
+ clientSecret?: string;
29
+ onEvent?: (event: { type: string; [key: string]: any }) => void;
30
+ }
31
+
32
+ export interface BatchUploadResult {
33
+ targetFolder: string;
34
+ total: number;
35
+ uploaded: Array<{ filename: string; videoId: string; url: string; addedToPlaylist: boolean }>;
36
+ skipped: Array<{ filename: string; videoId?: string; reason: string }>;
37
+ failed: Array<{ filename: string; error: Error }>;
38
+ estimatedQuota: number;
39
+ }
40
+
41
+ export declare class UploadState {
42
+ constructor(targetFolder?: string, stateFilename?: string);
43
+ getUpload(filename: string): { videoId: string; uploadedAt: string; playlistId?: string } | null;
44
+ recordUpload(filename: string, videoId: string, playlistId?: string | null): void;
45
+ recordPlaylist(filename: string, playlistId: string): void;
46
+ save(): void;
47
+ }
48
+
49
+ export function loadMetadata(targetFolder?: string, customMetadataPath?: string): MetadataConfig;
50
+ export function authenticate(options?: {
51
+ clientId?: string;
52
+ clientSecret?: string;
53
+ clientSecretsFile?: string;
54
+ tokenFile?: string;
55
+ redirectUri?: string;
56
+ noBrowser?: boolean;
57
+ }): Promise<OAuth2Client>;
58
+
59
+ export function uploadVideo(
60
+ youtube: youtube_v3.Youtube,
61
+ filePath: string,
62
+ meta: VideoMetadata,
63
+ defaultPrivacy?: string,
64
+ defaultCategory?: string
65
+ ): Promise<{ videoId: string; url: string }>;
66
+
67
+ export function addToPlaylist(
68
+ youtube: youtube_v3.Youtube,
69
+ playlistId: string,
70
+ videoId: string
71
+ ): Promise<any>;
72
+
73
+ export function batchUpload(
74
+ targetFolder?: string,
75
+ options?: BatchUploadOptions
76
+ ): Promise<BatchUploadResult>;
77
+
78
+ export const QUOTA_PER_VIDEO_UPLOAD: number;
79
+ export const QUOTA_PER_PLAYLIST_ITEM: number;
80
+ export const DAILY_DEFAULT_QUOTA: number;
81
+ export const DEFAULT_CATEGORY_ID: string;
82
+ export const DEFAULT_PRIVACY_STATUS: string;
83
+ export const VALID_PRIVACY_STATUSES: Set<string>;
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export { authenticate, resolveOAuthCredentials, YOUTUBE_SCOPES } from './auth.js';
2
+ export { loadMetadata, DEFAULT_CATEGORY_ID, DEFAULT_PRIVACY_STATUS, VALID_PRIVACY_STATUSES } from './metadata.js';
3
+ export { UploadState, DEFAULT_STATE_FILENAME } from './state.js';
4
+ export {
5
+ uploadVideo,
6
+ addToPlaylist,
7
+ batchUpload,
8
+ QUOTA_PER_VIDEO_UPLOAD,
9
+ QUOTA_PER_PLAYLIST_ITEM,
10
+ DAILY_DEFAULT_QUOTA,
11
+ } from './uploader.js';
@@ -0,0 +1,108 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const VALID_PRIVACY_STATUSES = new Set(['private', 'unlisted', 'public']);
5
+
6
+ // YouTube Category IDs reference:
7
+ // 10 = Music, 22 = People & Blogs, 27 = Education, 28 = Science & Technology, etc.
8
+ export const DEFAULT_CATEGORY_ID = '10'; // Music
9
+ export const DEFAULT_PRIVACY_STATUS = 'private';
10
+
11
+ /**
12
+ * Load, validate, and normalize metadata for batch video uploading.
13
+ *
14
+ * @param {string} targetFolder - Path to folder containing videos
15
+ * @param {string} [customMetadataPath] - Optional custom path to metadata.json
16
+ * @returns {{
17
+ * playlistId: string | null,
18
+ * privacyStatus: 'private' | 'unlisted' | 'public',
19
+ * categoryId: string,
20
+ * videos: Array<{
21
+ * filename: string,
22
+ * title: string,
23
+ * description: string,
24
+ * tags: string[],
25
+ * categoryId?: string,
26
+ * privacyStatus?: 'private' | 'unlisted' | 'public',
27
+ * filePath: string
28
+ * }>
29
+ * }}
30
+ */
31
+ export function loadMetadata(targetFolder, customMetadataPath) {
32
+ const resolvedFolder = path.resolve(targetFolder || process.cwd());
33
+ const metadataPath = customMetadataPath
34
+ ? path.resolve(customMetadataPath)
35
+ : path.join(resolvedFolder, 'metadata.json');
36
+
37
+ let rawConfig = {};
38
+
39
+ if (fs.existsSync(metadataPath)) {
40
+ try {
41
+ const content = fs.readFileSync(metadataPath, 'utf-8');
42
+ rawConfig = JSON.parse(content);
43
+ } catch (err) {
44
+ throw new Error(`Failed to parse metadata file (${metadataPath}): ${err.message}`);
45
+ }
46
+ } else {
47
+ // If metadata.json does not exist, check if there are .mp4 files we can auto-discover
48
+ if (customMetadataPath) {
49
+ throw new Error(`Specified metadata file does not exist: ${metadataPath}`);
50
+ }
51
+ }
52
+
53
+ const defaultPrivacy = VALID_PRIVACY_STATUSES.has(rawConfig.privacyStatus)
54
+ ? rawConfig.privacyStatus
55
+ : DEFAULT_PRIVACY_STATUS;
56
+
57
+ const defaultCategory = String(rawConfig.categoryId || DEFAULT_CATEGORY_ID);
58
+ const playlistId = rawConfig.playlistId ? String(rawConfig.playlistId).trim() : null;
59
+
60
+ let rawVideos = Array.isArray(rawConfig.videos) ? rawConfig.videos : [];
61
+
62
+ // If no videos array was provided in metadata.json, auto-discover all .mp4 files in the folder
63
+ if (rawVideos.length === 0 && fs.existsSync(resolvedFolder)) {
64
+ const files = fs.readdirSync(resolvedFolder);
65
+ const mp4Files = files.filter(f => f.toLowerCase().endsWith('.mp4') && !f.startsWith('.'));
66
+ rawVideos = mp4Files.map(filename => ({
67
+ filename,
68
+ title: path.basename(filename, path.extname(filename)),
69
+ description: '',
70
+ tags: [],
71
+ }));
72
+ }
73
+
74
+ const videos = rawVideos.map((item, index) => {
75
+ if (!item.filename) {
76
+ throw new Error(`Video at index ${index} in metadata.json is missing required "filename" property.`);
77
+ }
78
+
79
+ const filename = String(item.filename);
80
+ 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) : '';
83
+ const tags = Array.isArray(item.tags) ? item.tags.map(t => String(t).trim()).filter(Boolean) : [];
84
+
85
+ const privacyStatus = item.privacyStatus && VALID_PRIVACY_STATUSES.has(item.privacyStatus)
86
+ ? item.privacyStatus
87
+ : defaultPrivacy;
88
+
89
+ const categoryId = item.categoryId ? String(item.categoryId) : defaultCategory;
90
+
91
+ return {
92
+ filename,
93
+ filePath,
94
+ title,
95
+ description,
96
+ tags,
97
+ categoryId,
98
+ privacyStatus,
99
+ };
100
+ });
101
+
102
+ return {
103
+ playlistId,
104
+ privacyStatus: defaultPrivacy,
105
+ categoryId: defaultCategory,
106
+ videos,
107
+ };
108
+ }
package/src/state.js ADDED
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const DEFAULT_STATE_FILENAME = '.upload-history.json';
5
+
6
+ /**
7
+ * State manager to keep track of uploaded videos and playlist statuses.
8
+ */
9
+ export class UploadState {
10
+ /**
11
+ * @param {string} targetFolder
12
+ * @param {string} [stateFilename]
13
+ */
14
+ constructor(targetFolder, stateFilename = DEFAULT_STATE_FILENAME) {
15
+ this.targetFolder = path.resolve(targetFolder || process.cwd());
16
+ this.statePath = path.join(this.targetFolder, stateFilename);
17
+ this.data = this._load();
18
+ }
19
+
20
+ _load() {
21
+ if (fs.existsSync(this.statePath)) {
22
+ try {
23
+ const content = fs.readFileSync(this.statePath, 'utf-8');
24
+ return JSON.parse(content);
25
+ } catch {
26
+ return { uploads: {} };
27
+ }
28
+ }
29
+ return { uploads: {} };
30
+ }
31
+
32
+ save() {
33
+ try {
34
+ fs.writeFileSync(this.statePath, JSON.stringify(this.data, null, 2), 'utf-8');
35
+ } catch (err) {
36
+ console.warn(`Warning: Could not save upload state to ${this.statePath}: ${err.message}`);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Check if a file has already been uploaded.
42
+ * @param {string} filename
43
+ * @returns {{ videoId: string, uploadedAt: string, playlistId?: string } | null}
44
+ */
45
+ getUpload(filename) {
46
+ const key = path.basename(filename);
47
+ return this.data.uploads[key] || null;
48
+ }
49
+
50
+ /**
51
+ * Record a successful upload.
52
+ * @param {string} filename
53
+ * @param {string} videoId
54
+ * @param {string} [playlistId]
55
+ */
56
+ recordUpload(filename, videoId, playlistId = null) {
57
+ const key = path.basename(filename);
58
+ this.data.uploads[key] = {
59
+ videoId,
60
+ uploadedAt: new Date().toISOString(),
61
+ playlistId: playlistId || (this.data.uploads[key]?.playlistId ?? null),
62
+ };
63
+ this.save();
64
+ }
65
+
66
+ /**
67
+ * Record playlist association.
68
+ * @param {string} filename
69
+ * @param {string} playlistId
70
+ */
71
+ recordPlaylist(filename, playlistId) {
72
+ const key = path.basename(filename);
73
+ if (!this.data.uploads[key]) {
74
+ this.data.uploads[key] = { videoId: null, uploadedAt: null };
75
+ }
76
+ this.data.uploads[key].playlistId = playlistId;
77
+ this.save();
78
+ }
79
+ }
@@ -0,0 +1,232 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { google } from 'googleapis';
4
+ import { loadMetadata } from './metadata.js';
5
+ import { UploadState } from './state.js';
6
+ import { authenticate } from './auth.js';
7
+
8
+ export const QUOTA_PER_VIDEO_UPLOAD = 1600;
9
+ export const QUOTA_PER_PLAYLIST_ITEM = 50;
10
+ export const DAILY_DEFAULT_QUOTA = 10000;
11
+
12
+ /**
13
+ * Upload a single video file to YouTube via YouTube Data API v3.
14
+ *
15
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
16
+ * @param {string} filePath
17
+ * @param {object} meta
18
+ * @param {string} [defaultPrivacy='private']
19
+ * @param {string} [defaultCategory='10']
20
+ * @returns {Promise<{ videoId: string, url: string }>}
21
+ */
22
+ export async function uploadVideo(youtube, filePath, meta, defaultPrivacy = 'private', defaultCategory = '10') {
23
+ if (!fs.existsSync(filePath)) {
24
+ throw new Error(`Video file not found: ${filePath}`);
25
+ }
26
+
27
+ const stat = fs.statSync(filePath);
28
+ if (stat.size === 0) {
29
+ throw new Error(`Video file is empty (0 bytes): ${filePath}`);
30
+ }
31
+
32
+ const res = await youtube.videos.insert({
33
+ part: ['snippet', 'status'],
34
+ 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
+ },
44
+ },
45
+ media: {
46
+ body: fs.createReadStream(filePath),
47
+ },
48
+ });
49
+
50
+ const videoId = res.data.id;
51
+ return {
52
+ videoId,
53
+ url: `https://youtu.be/${videoId}`,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Add a video to a YouTube Playlist.
59
+ *
60
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
61
+ * @param {string} playlistId
62
+ * @param {string} videoId
63
+ * @returns {Promise<any>}
64
+ */
65
+ export async function addToPlaylist(youtube, playlistId, videoId) {
66
+ const res = await youtube.playlistItems.insert({
67
+ part: ['snippet'],
68
+ requestBody: {
69
+ snippet: {
70
+ playlistId,
71
+ resourceId: {
72
+ kind: 'youtube#video',
73
+ videoId,
74
+ },
75
+ },
76
+ },
77
+ });
78
+ return res.data;
79
+ }
80
+
81
+ /**
82
+ * Batch upload all videos from a directory using metadata.json.
83
+ *
84
+ * @param {string} targetFolder - Directory containing videos and metadata.json
85
+ * @param {object} [options]
86
+ * @param {string} [options.metadataFile] - Custom metadata JSON path
87
+ * @param {boolean} [options.force=false] - Re-upload previously uploaded videos
88
+ * @param {boolean} [options.dryRun=false] - Preview without uploading
89
+ * @param {string} [options.tokenFile] - Custom token JSON path
90
+ * @param {string} [options.clientSecretsFile] - Custom client_secret.json path
91
+ * @param {(event: { type: string, [key: string]: any }) => void} [options.onEvent]
92
+ * @returns {Promise<{
93
+ * targetFolder: string,
94
+ * total: number,
95
+ * uploaded: Array<{ filename: string, videoId: string, url: string, addedToPlaylist: boolean }>,
96
+ * skipped: Array<{ filename: string, videoId?: string, reason: string }>,
97
+ * failed: Array<{ filename: string, error: Error }>,
98
+ * estimatedQuota: number
99
+ * }>}
100
+ */
101
+ export async function batchUpload(targetFolder, options = {}) {
102
+ const resolvedFolder = path.resolve(targetFolder || process.cwd());
103
+ const force = Boolean(options.force);
104
+ const dryRun = Boolean(options.dryRun);
105
+ const onEvent = options.onEvent || (() => {});
106
+
107
+ const config = loadMetadata(resolvedFolder, options.metadataFile);
108
+ const state = new UploadState(resolvedFolder);
109
+
110
+ const playlistId = config.playlistId;
111
+ const toProcess = [];
112
+ const skipped = [];
113
+
114
+ for (const item of config.videos) {
115
+ if (!fs.existsSync(item.filePath)) {
116
+ skipped.push({
117
+ filename: item.filename,
118
+ reason: 'File not found on disk',
119
+ });
120
+ continue;
121
+ }
122
+
123
+ const previousUpload = state.getUpload(item.filename);
124
+ if (previousUpload && !force) {
125
+ skipped.push({
126
+ filename: item.filename,
127
+ videoId: previousUpload.videoId,
128
+ reason: `Already uploaded (Video ID: ${previousUpload.videoId})`,
129
+ });
130
+ } else {
131
+ toProcess.push(item);
132
+ }
133
+ }
134
+
135
+ const estimatedQuota = (toProcess.length * QUOTA_PER_VIDEO_UPLOAD) +
136
+ (playlistId ? toProcess.length * QUOTA_PER_PLAYLIST_ITEM : 0);
137
+
138
+ onEvent({
139
+ type: 'plan',
140
+ total: config.videos.length,
141
+ toProcess,
142
+ skipped,
143
+ estimatedQuota,
144
+ playlistId,
145
+ });
146
+
147
+ if (dryRun) {
148
+ return {
149
+ targetFolder: resolvedFolder,
150
+ total: config.videos.length,
151
+ uploaded: [],
152
+ skipped,
153
+ failed: [],
154
+ estimatedQuota,
155
+ };
156
+ }
157
+
158
+ if (toProcess.length === 0) {
159
+ return {
160
+ targetFolder: resolvedFolder,
161
+ total: config.videos.length,
162
+ uploaded: [],
163
+ skipped,
164
+ failed: [],
165
+ estimatedQuota: 0,
166
+ };
167
+ }
168
+
169
+ // Authenticate
170
+ const auth = await authenticate({
171
+ tokenFile: options.tokenFile,
172
+ clientSecretsFile: options.clientSecretsFile,
173
+ clientId: options.clientId,
174
+ clientSecret: options.clientSecret,
175
+ });
176
+
177
+ const youtube = google.youtube({ version: 'v3', auth });
178
+
179
+ const uploaded = [];
180
+ const failed = [];
181
+
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 });
185
+
186
+ try {
187
+ const uploadRes = await uploadVideo(
188
+ youtube,
189
+ item.filePath,
190
+ item,
191
+ config.privacyStatus,
192
+ config.categoryId
193
+ );
194
+
195
+ let addedToPlaylist = false;
196
+ if (playlistId) {
197
+ onEvent({ type: 'playlist_start', item, videoId: uploadRes.videoId, playlistId });
198
+ try {
199
+ await addToPlaylist(youtube, playlistId, uploadRes.videoId);
200
+ addedToPlaylist = true;
201
+ onEvent({ type: 'playlist_success', item, videoId: uploadRes.videoId, playlistId });
202
+ } catch (playlistErr) {
203
+ onEvent({ type: 'playlist_error', item, videoId: uploadRes.videoId, error: playlistErr });
204
+ }
205
+ }
206
+
207
+ state.recordUpload(item.filename, uploadRes.videoId, addedToPlaylist ? playlistId : null);
208
+
209
+ const record = {
210
+ filename: item.filename,
211
+ videoId: uploadRes.videoId,
212
+ url: uploadRes.url,
213
+ addedToPlaylist,
214
+ };
215
+
216
+ uploaded.push(record);
217
+ onEvent({ type: 'upload_success', item, record });
218
+ } catch (err) {
219
+ failed.push({ filename: item.filename, error: err });
220
+ onEvent({ type: 'upload_error', item, error: err });
221
+ }
222
+ }
223
+
224
+ return {
225
+ targetFolder: resolvedFolder,
226
+ total: config.videos.length,
227
+ uploaded,
228
+ skipped,
229
+ failed,
230
+ estimatedQuota,
231
+ };
232
+ }