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