@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/src/uploader.js
CHANGED
|
@@ -7,8 +7,36 @@ import { authenticate } from './auth.js';
|
|
|
7
7
|
|
|
8
8
|
export const QUOTA_PER_VIDEO_UPLOAD = 1600;
|
|
9
9
|
export const QUOTA_PER_PLAYLIST_ITEM = 50;
|
|
10
|
+
export const QUOTA_PER_PLAYLIST_CREATE = 50;
|
|
10
11
|
export const DAILY_DEFAULT_QUOTA = 10000;
|
|
11
12
|
|
|
13
|
+
import { interpolateTemplates } from './metadata.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a YouTube Playlist.
|
|
17
|
+
*
|
|
18
|
+
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
19
|
+
* @param {object} snippet
|
|
20
|
+
* @param {object} [status={ privacyStatus: 'private' }]
|
|
21
|
+
* @returns {Promise<{ playlistId: string, url: string, data: any }>}
|
|
22
|
+
*/
|
|
23
|
+
export async function createPlaylist(youtube, snippet, status = { privacyStatus: 'private' }) {
|
|
24
|
+
const res = await youtube.playlists.insert({
|
|
25
|
+
part: ['snippet', 'status'],
|
|
26
|
+
requestBody: {
|
|
27
|
+
snippet,
|
|
28
|
+
status,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const playlistId = res.data.id;
|
|
33
|
+
return {
|
|
34
|
+
playlistId,
|
|
35
|
+
url: `https://www.youtube.com/playlist?list=${playlistId}`,
|
|
36
|
+
data: res.data,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
12
40
|
/**
|
|
13
41
|
* Upload a single video file to YouTube via YouTube Data API v3.
|
|
14
42
|
*
|
|
@@ -29,23 +57,37 @@ export async function uploadVideo(youtube, filePath, meta, defaultPrivacy = 'pri
|
|
|
29
57
|
throw new Error(`Video file is empty (0 bytes): ${filePath}`);
|
|
30
58
|
}
|
|
31
59
|
|
|
32
|
-
const
|
|
60
|
+
const snippet = {
|
|
61
|
+
title: meta.title,
|
|
62
|
+
description: meta.description || '',
|
|
63
|
+
tags: meta.tags || [],
|
|
64
|
+
categoryId: String(meta.categoryId || defaultCategory || '10'),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (meta.defaultLanguage) snippet.defaultLanguage = meta.defaultLanguage;
|
|
68
|
+
if (meta.defaultAudioLanguage) snippet.defaultAudioLanguage = meta.defaultAudioLanguage;
|
|
69
|
+
|
|
70
|
+
const status = {
|
|
71
|
+
privacyStatus: meta.privacyStatus || defaultPrivacy || 'private',
|
|
72
|
+
license: meta.license || 'youtube',
|
|
73
|
+
embeddable: meta.embeddable !== undefined ? meta.embeddable : true,
|
|
74
|
+
publicStatsViewable: meta.publicStatsViewable !== undefined ? meta.publicStatsViewable : true,
|
|
75
|
+
selfDeclaredMadeForKids: meta.selfDeclaredMadeForKids !== undefined ? meta.selfDeclaredMadeForKids : false,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const insertParams = {
|
|
33
79
|
part: ['snippet', 'status'],
|
|
80
|
+
notifySubscribers: meta.notifySubscribers !== undefined ? meta.notifySubscribers : false,
|
|
34
81
|
requestBody: {
|
|
35
|
-
snippet
|
|
36
|
-
|
|
37
|
-
description: meta.description || '',
|
|
38
|
-
tags: meta.tags || [],
|
|
39
|
-
categoryId: meta.categoryId || defaultCategory || '10',
|
|
40
|
-
},
|
|
41
|
-
status: {
|
|
42
|
-
privacyStatus: meta.privacyStatus || defaultPrivacy || 'private',
|
|
43
|
-
},
|
|
82
|
+
snippet,
|
|
83
|
+
status,
|
|
44
84
|
},
|
|
45
85
|
media: {
|
|
46
86
|
body: fs.createReadStream(filePath),
|
|
47
87
|
},
|
|
48
|
-
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const res = await youtube.videos.insert(insertParams);
|
|
49
91
|
|
|
50
92
|
const videoId = res.data.id;
|
|
51
93
|
return {
|
|
@@ -60,19 +102,25 @@ export async function uploadVideo(youtube, filePath, meta, defaultPrivacy = 'pri
|
|
|
60
102
|
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
61
103
|
* @param {string} playlistId
|
|
62
104
|
* @param {string} videoId
|
|
105
|
+
* @param {number} [position]
|
|
63
106
|
* @returns {Promise<any>}
|
|
64
107
|
*/
|
|
65
|
-
export async function addToPlaylist(youtube, playlistId, videoId) {
|
|
108
|
+
export async function addToPlaylist(youtube, playlistId, videoId, position) {
|
|
109
|
+
const snippet = {
|
|
110
|
+
playlistId,
|
|
111
|
+
resourceId: {
|
|
112
|
+
kind: 'youtube#video',
|
|
113
|
+
videoId,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
if (typeof position === 'number') {
|
|
117
|
+
snippet.position = position;
|
|
118
|
+
}
|
|
119
|
+
|
|
66
120
|
const res = await youtube.playlistItems.insert({
|
|
67
121
|
part: ['snippet'],
|
|
68
122
|
requestBody: {
|
|
69
|
-
snippet
|
|
70
|
-
playlistId,
|
|
71
|
-
resourceId: {
|
|
72
|
-
kind: 'youtube#video',
|
|
73
|
-
videoId,
|
|
74
|
-
},
|
|
75
|
-
},
|
|
123
|
+
snippet,
|
|
76
124
|
},
|
|
77
125
|
});
|
|
78
126
|
return res.data;
|
|
@@ -95,7 +143,8 @@ export async function addToPlaylist(youtube, playlistId, videoId) {
|
|
|
95
143
|
* uploaded: Array<{ filename: string, videoId: string, url: string, addedToPlaylist: boolean }>,
|
|
96
144
|
* skipped: Array<{ filename: string, videoId?: string, reason: string }>,
|
|
97
145
|
* failed: Array<{ filename: string, error: Error }>,
|
|
98
|
-
* estimatedQuota: number
|
|
146
|
+
* estimatedQuota: number,
|
|
147
|
+
* playlistId?: string | null
|
|
99
148
|
* }>}
|
|
100
149
|
*/
|
|
101
150
|
export async function batchUpload(targetFolder, options = {}) {
|
|
@@ -107,7 +156,11 @@ export async function batchUpload(targetFolder, options = {}) {
|
|
|
107
156
|
const config = loadMetadata(resolvedFolder, options.metadataFile);
|
|
108
157
|
const state = new UploadState(resolvedFolder);
|
|
109
158
|
|
|
110
|
-
const
|
|
159
|
+
const playlistKey = config.playlist?.key || 'default';
|
|
160
|
+
const cachedPlaylist = state.getPlaylist(playlistKey);
|
|
161
|
+
let playlistId = config.playlistId || cachedPlaylist?.playlistId || null;
|
|
162
|
+
const needsPlaylistCreation = !playlistId && Boolean(config.playlist?.snippet);
|
|
163
|
+
|
|
111
164
|
const toProcess = [];
|
|
112
165
|
const skipped = [];
|
|
113
166
|
|
|
@@ -132,16 +185,53 @@ export async function batchUpload(targetFolder, options = {}) {
|
|
|
132
185
|
}
|
|
133
186
|
}
|
|
134
187
|
|
|
135
|
-
const
|
|
136
|
-
|
|
188
|
+
const willHavePlaylist = Boolean(playlistId || needsPlaylistCreation);
|
|
189
|
+
const baseQuota = needsPlaylistCreation ? QUOTA_PER_PLAYLIST_CREATE : 0;
|
|
190
|
+
const costPerVideo = QUOTA_PER_VIDEO_UPLOAD + (willHavePlaylist ? QUOTA_PER_PLAYLIST_ITEM : 0);
|
|
191
|
+
|
|
192
|
+
let limit = Infinity;
|
|
193
|
+
if (options.limit !== undefined && options.limit !== null && !isNaN(options.limit)) {
|
|
194
|
+
limit = Math.max(0, parseInt(options.limit, 10));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let maxQuota = Infinity;
|
|
198
|
+
if (options.maxQuota !== undefined && options.maxQuota !== null && !isNaN(options.maxQuota)) {
|
|
199
|
+
maxQuota = parseInt(options.maxQuota, 10);
|
|
200
|
+
} else if (options.safeQuota) {
|
|
201
|
+
maxQuota = DAILY_DEFAULT_QUOTA - 50; // 9,950 units safe limit
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const activeToProcess = [];
|
|
205
|
+
const deferred = [];
|
|
206
|
+
|
|
207
|
+
for (const item of toProcess) {
|
|
208
|
+
const candidateQuota = baseQuota + ((activeToProcess.length + 1) * costPerVideo);
|
|
209
|
+
if (activeToProcess.length < limit && candidateQuota <= maxQuota) {
|
|
210
|
+
activeToProcess.push(item);
|
|
211
|
+
} else {
|
|
212
|
+
deferred.push({
|
|
213
|
+
filename: item.filename,
|
|
214
|
+
reason: activeToProcess.length >= limit
|
|
215
|
+
? `Deferred by upload limit (${limit})`
|
|
216
|
+
: `Deferred for next batch to stay under quota limit (${maxQuota} units)`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const estimatedQuota = baseQuota + (activeToProcess.length * costPerVideo);
|
|
137
222
|
|
|
138
223
|
onEvent({
|
|
139
224
|
type: 'plan',
|
|
140
225
|
total: config.videos.length,
|
|
141
|
-
toProcess,
|
|
226
|
+
toProcess: activeToProcess,
|
|
142
227
|
skipped,
|
|
228
|
+
deferred,
|
|
143
229
|
estimatedQuota,
|
|
144
230
|
playlistId,
|
|
231
|
+
needsPlaylistCreation,
|
|
232
|
+
playlist: config.playlist,
|
|
233
|
+
safeQuota: Boolean(options.safeQuota),
|
|
234
|
+
limit: isFinite(limit) ? limit : null,
|
|
145
235
|
});
|
|
146
236
|
|
|
147
237
|
if (dryRun) {
|
|
@@ -150,19 +240,23 @@ export async function batchUpload(targetFolder, options = {}) {
|
|
|
150
240
|
total: config.videos.length,
|
|
151
241
|
uploaded: [],
|
|
152
242
|
skipped,
|
|
243
|
+
deferred,
|
|
153
244
|
failed: [],
|
|
154
245
|
estimatedQuota,
|
|
246
|
+
playlistId,
|
|
155
247
|
};
|
|
156
248
|
}
|
|
157
249
|
|
|
158
|
-
if (
|
|
250
|
+
if (activeToProcess.length === 0 && !needsPlaylistCreation) {
|
|
159
251
|
return {
|
|
160
252
|
targetFolder: resolvedFolder,
|
|
161
253
|
total: config.videos.length,
|
|
162
254
|
uploaded: [],
|
|
163
255
|
skipped,
|
|
256
|
+
deferred,
|
|
164
257
|
failed: [],
|
|
165
258
|
estimatedQuota: 0,
|
|
259
|
+
playlistId,
|
|
166
260
|
};
|
|
167
261
|
}
|
|
168
262
|
|
|
@@ -176,12 +270,61 @@ export async function batchUpload(targetFolder, options = {}) {
|
|
|
176
270
|
|
|
177
271
|
const youtube = google.youtube({ version: 'v3', auth });
|
|
178
272
|
|
|
273
|
+
// Verify and display authenticated channel identity
|
|
274
|
+
try {
|
|
275
|
+
const channelRes = await youtube.channels.list({
|
|
276
|
+
part: ['snippet'],
|
|
277
|
+
mine: true,
|
|
278
|
+
});
|
|
279
|
+
if (channelRes.data.items && channelRes.data.items.length > 0) {
|
|
280
|
+
const ch = channelRes.data.items[0];
|
|
281
|
+
const channelInfo = {
|
|
282
|
+
id: ch.id,
|
|
283
|
+
title: ch.snippet?.title || 'Unknown',
|
|
284
|
+
customUrl: ch.snippet?.customUrl || null,
|
|
285
|
+
};
|
|
286
|
+
onEvent({ type: 'authenticated_channel', channel: channelInfo });
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
// Non-fatal
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Create playlist if needed
|
|
293
|
+
if (needsPlaylistCreation && config.playlist?.snippet) {
|
|
294
|
+
onEvent({ type: 'playlist_create_start', playlist: config.playlist });
|
|
295
|
+
try {
|
|
296
|
+
const created = await createPlaylist(
|
|
297
|
+
youtube,
|
|
298
|
+
config.playlist.snippet,
|
|
299
|
+
config.playlist.status || { privacyStatus: config.privacyStatus }
|
|
300
|
+
);
|
|
301
|
+
playlistId = created.playlistId;
|
|
302
|
+
state.recordPlaylistInfo(playlistKey, created.playlistId, created.url);
|
|
303
|
+
onEvent({ type: 'playlist_create_success', playlistId, url: created.url });
|
|
304
|
+
} catch (err) {
|
|
305
|
+
onEvent({ type: 'playlist_create_error', error: err });
|
|
306
|
+
throw new Error(`Failed to create YouTube Playlist: ${err.message}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Apply playlistUrl template interpolation to pending video items
|
|
311
|
+
const playlistUrl = playlistId ? `https://www.youtube.com/playlist?list=${playlistId}` : '';
|
|
312
|
+
const finalTemplateVars = {
|
|
313
|
+
...config.templateVariables,
|
|
314
|
+
playlistUrl: playlistUrl || config.templateVariables.playlistUrl || '',
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
for (const item of activeToProcess) {
|
|
318
|
+
item.title = interpolateTemplates(item.title, finalTemplateVars);
|
|
319
|
+
item.description = interpolateTemplates(item.description, finalTemplateVars);
|
|
320
|
+
}
|
|
321
|
+
|
|
179
322
|
const uploaded = [];
|
|
180
323
|
const failed = [];
|
|
181
324
|
|
|
182
|
-
for (let i = 0; i <
|
|
183
|
-
const item =
|
|
184
|
-
onEvent({ type: 'upload_start', item, index: i, total:
|
|
325
|
+
for (let i = 0; i < activeToProcess.length; i++) {
|
|
326
|
+
const item = activeToProcess[i];
|
|
327
|
+
onEvent({ type: 'upload_start', item, index: i, total: activeToProcess.length });
|
|
185
328
|
|
|
186
329
|
try {
|
|
187
330
|
const uploadRes = await uploadVideo(
|
|
@@ -196,7 +339,7 @@ export async function batchUpload(targetFolder, options = {}) {
|
|
|
196
339
|
if (playlistId) {
|
|
197
340
|
onEvent({ type: 'playlist_start', item, videoId: uploadRes.videoId, playlistId });
|
|
198
341
|
try {
|
|
199
|
-
await addToPlaylist(youtube, playlistId, uploadRes.videoId);
|
|
342
|
+
await addToPlaylist(youtube, playlistId, uploadRes.videoId, item.playlistPosition);
|
|
200
343
|
addedToPlaylist = true;
|
|
201
344
|
onEvent({ type: 'playlist_success', item, videoId: uploadRes.videoId, playlistId });
|
|
202
345
|
} catch (playlistErr) {
|
|
@@ -228,5 +371,6 @@ export async function batchUpload(targetFolder, options = {}) {
|
|
|
228
371
|
skipped,
|
|
229
372
|
failed,
|
|
230
373
|
estimatedQuota,
|
|
374
|
+
playlistId,
|
|
231
375
|
};
|
|
232
376
|
}
|