@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 +21 -0
- package/README.md +169 -0
- package/bin/cli.js +149 -0
- package/package.json +52 -0
- package/src/auth.js +235 -0
- package/src/index.d.ts +83 -0
- package/src/index.js +11 -0
- package/src/metadata.js +108 -0
- package/src/state.js +79 -0
- package/src/uploader.js +232 -0
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';
|
package/src/metadata.js
ADDED
|
@@ -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
|
+
}
|
package/src/uploader.js
ADDED
|
@@ -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
|
+
}
|