@x12i/youtube-video-uploader-cli 1.2.0 → 1.3.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 +338 -27
- package/bin/cli.js +620 -2
- package/package.json +7 -1
- package/src/actions/index.js +274 -0
- package/src/actions/journey-overlay.js +567 -0
- package/src/actions/replace-background.js +154 -0
- package/src/actions/shorten.js +271 -0
- package/src/batch/clone-metadata.js +51 -0
- package/src/batch/plan.js +194 -0
- package/src/cli/apply-actions.js +152 -0
- package/src/cli/preview-journey.js +106 -0
- package/src/index.d.ts +525 -3
- package/src/index.js +82 -1
- package/src/media/cache.js +134 -0
- package/src/media/compose.js +227 -0
- package/src/media/download.js +151 -0
- package/src/media/resolve.js +476 -0
- package/src/media/select.js +176 -0
- package/src/providers/index.js +235 -0
- package/src/providers/pexels.js +171 -0
- package/src/providers/pixabay.js +184 -0
- package/src/state.js +11 -0
- package/src/update.js +392 -0
- package/src/uploader.js +20 -0
package/src/update.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
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_UPDATE = 50;
|
|
9
|
+
export const QUOTA_PER_PLAYLIST_UPDATE = 50;
|
|
10
|
+
export const QUOTA_PER_LIST = 1;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Update snippet fields of a YouTube video while preserving other metadata.
|
|
14
|
+
*
|
|
15
|
+
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
16
|
+
* @param {string} videoId
|
|
17
|
+
* @param {object} snippetUpdates - Fields to update (title, description, tags, categoryId, etc.)
|
|
18
|
+
* @returns {Promise<{ videoId: string, title: string, data: any }>}
|
|
19
|
+
*/
|
|
20
|
+
export async function updateVideoSnippet(youtube, videoId, snippetUpdates) {
|
|
21
|
+
if (!videoId) {
|
|
22
|
+
throw new Error('updateVideoSnippet requires a videoId');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Fetch current snippet to preserve mandatory categoryId and other existing fields
|
|
26
|
+
const res = await youtube.videos.list({
|
|
27
|
+
part: ['snippet'],
|
|
28
|
+
id: [videoId],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const existing = res.data.items?.[0];
|
|
32
|
+
if (!existing || !existing.snippet) {
|
|
33
|
+
throw new Error(`Video not found or inaccessible: ${videoId}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mergedSnippet = {
|
|
37
|
+
...existing.snippet,
|
|
38
|
+
...snippetUpdates,
|
|
39
|
+
categoryId: String(snippetUpdates.categoryId || existing.snippet.categoryId || '10'),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const updateRes = await youtube.videos.update({
|
|
43
|
+
part: ['snippet'],
|
|
44
|
+
requestBody: {
|
|
45
|
+
id: videoId,
|
|
46
|
+
snippet: mergedSnippet,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
videoId,
|
|
52
|
+
title: updateRes.data.snippet?.title || mergedSnippet.title,
|
|
53
|
+
data: updateRes.data,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Update a YouTube video's title.
|
|
59
|
+
*
|
|
60
|
+
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
61
|
+
* @param {string} videoId
|
|
62
|
+
* @param {string} newTitle
|
|
63
|
+
* @returns {Promise<{ videoId: string, title: string, data: any }>}
|
|
64
|
+
*/
|
|
65
|
+
export async function updateVideoTitle(youtube, videoId, newTitle) {
|
|
66
|
+
return updateVideoSnippet(youtube, videoId, { title: newTitle });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Update snippet fields of a YouTube playlist while preserving existing description/tags.
|
|
71
|
+
*
|
|
72
|
+
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
73
|
+
* @param {string} playlistId
|
|
74
|
+
* @param {object} snippetUpdates
|
|
75
|
+
* @returns {Promise<{ playlistId: string, title: string, data: any }>}
|
|
76
|
+
*/
|
|
77
|
+
export async function updatePlaylistSnippet(youtube, playlistId, snippetUpdates) {
|
|
78
|
+
if (!playlistId) {
|
|
79
|
+
throw new Error('updatePlaylistSnippet requires a playlistId');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const res = await youtube.playlists.list({
|
|
83
|
+
part: ['snippet'],
|
|
84
|
+
id: [playlistId],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const existing = res.data.items?.[0];
|
|
88
|
+
if (!existing || !existing.snippet) {
|
|
89
|
+
throw new Error(`Playlist not found or inaccessible: ${playlistId}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const mergedSnippet = {
|
|
93
|
+
...existing.snippet,
|
|
94
|
+
...snippetUpdates,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const updateRes = await youtube.playlists.update({
|
|
98
|
+
part: ['snippet'],
|
|
99
|
+
requestBody: {
|
|
100
|
+
id: playlistId,
|
|
101
|
+
snippet: mergedSnippet,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
playlistId,
|
|
107
|
+
title: updateRes.data.snippet?.title || mergedSnippet.title,
|
|
108
|
+
data: updateRes.data,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Update a YouTube playlist's title.
|
|
114
|
+
*
|
|
115
|
+
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
116
|
+
* @param {string} playlistId
|
|
117
|
+
* @param {string} newTitle
|
|
118
|
+
* @returns {Promise<{ playlistId: string, title: string, data: any }>}
|
|
119
|
+
*/
|
|
120
|
+
export async function updatePlaylistTitle(youtube, playlistId, newTitle) {
|
|
121
|
+
return updatePlaylistSnippet(youtube, playlistId, { title: newTitle });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Batch update titles / metadata for all videos and the playlist in a target directory.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} targetFolder
|
|
128
|
+
* @param {object} [options]
|
|
129
|
+
* @param {string} [options.titlePostfix] - Postfix to append to video titles
|
|
130
|
+
* @param {string} [options.titlePrefix] - Prefix to prepend to video titles
|
|
131
|
+
* @param {string} [options.playlistPostfix] - Postfix to append to playlist title
|
|
132
|
+
* @param {string} [options.playlistTitle] - Override playlist title
|
|
133
|
+
* @param {string} [options.replacePattern] - Regex or substring to find in titles
|
|
134
|
+
* @param {string} [options.replaceWith] - Replacement string
|
|
135
|
+
* @param {string} [options.metadataFile] - Custom path to metadata.json
|
|
136
|
+
* @param {boolean} [options.updateLocalMetadata=true] - Update metadata.json on disk
|
|
137
|
+
* @param {boolean} [options.syncMetadata=false] - Push titles from metadata.json directly to YouTube
|
|
138
|
+
* @param {string} [options.singleVideoId] - Optional single video ID to target
|
|
139
|
+
* @param {string} [options.singleVideoTitle] - Optional single video title override
|
|
140
|
+
* @param {string} [options.singlePlaylistId] - Optional single playlist ID to target
|
|
141
|
+
* @param {boolean} [options.dryRun=false]
|
|
142
|
+
* @param {string} [options.tokenFile]
|
|
143
|
+
* @param {string} [options.clientSecretsFile]
|
|
144
|
+
* @param {string} [options.clientId]
|
|
145
|
+
* @param {string} [options.clientSecret]
|
|
146
|
+
* @param {(event: { type: string, [key: string]: any }) => void} [options.onEvent]
|
|
147
|
+
* @returns {Promise<{
|
|
148
|
+
* playlist?: { playlistId: string, oldTitle: string, newTitle: string, changed: boolean },
|
|
149
|
+
* videos: Array<{ filename: string, videoId: string, oldTitle: string, newTitle: string, changed: boolean }>,
|
|
150
|
+
* estimatedQuota: number,
|
|
151
|
+
* dryRun: boolean
|
|
152
|
+
* }>}
|
|
153
|
+
*/
|
|
154
|
+
export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
155
|
+
const resolvedFolder = path.resolve(targetFolder || process.cwd());
|
|
156
|
+
const dryRun = Boolean(options.dryRun);
|
|
157
|
+
const onEvent = options.onEvent || (() => {});
|
|
158
|
+
const updateLocal = options.updateLocalMetadata !== false;
|
|
159
|
+
|
|
160
|
+
const metadataPath = options.metadataFile
|
|
161
|
+
? path.resolve(options.metadataFile)
|
|
162
|
+
: path.join(resolvedFolder, 'metadata.json');
|
|
163
|
+
|
|
164
|
+
let rawMetadata = null;
|
|
165
|
+
if (fs.existsSync(metadataPath)) {
|
|
166
|
+
try {
|
|
167
|
+
rawMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
|
168
|
+
} catch {
|
|
169
|
+
// Non-fatal if metadata.json is absent or invalid
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const state = new UploadState(resolvedFolder);
|
|
174
|
+
const playlistKey = rawMetadata?.playlist?.key || 'default';
|
|
175
|
+
const cachedPlaylist = state.getPlaylist(playlistKey);
|
|
176
|
+
const playlistId = options.singlePlaylistId || rawMetadata?.playlistId || rawMetadata?.playlist?.playlistId || cachedPlaylist?.playlistId || state.getPlaylist('gray_to_light')?.playlistId || null;
|
|
177
|
+
|
|
178
|
+
// Build playlist plan
|
|
179
|
+
let playlistPlan = null;
|
|
180
|
+
if (playlistId) {
|
|
181
|
+
let currentTitle = rawMetadata?.playlist?.youtube?.snippet?.title || rawMetadata?.playlist?.snippet?.title || '';
|
|
182
|
+
let newTitle = currentTitle;
|
|
183
|
+
|
|
184
|
+
if (options.syncMetadata) {
|
|
185
|
+
newTitle = currentTitle;
|
|
186
|
+
} else if (options.playlistTitle) {
|
|
187
|
+
newTitle = options.playlistTitle;
|
|
188
|
+
} else if (options.playlistPostfix) {
|
|
189
|
+
if (currentTitle && !currentTitle.endsWith(options.playlistPostfix)) {
|
|
190
|
+
newTitle = currentTitle + options.playlistPostfix;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (options.replacePattern) {
|
|
195
|
+
const reg = new RegExp(options.replacePattern, 'g');
|
|
196
|
+
newTitle = newTitle.replace(reg, options.replaceWith || '');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (currentTitle || newTitle || options.playlistPostfix) {
|
|
200
|
+
playlistPlan = {
|
|
201
|
+
playlistId,
|
|
202
|
+
oldTitle: currentTitle,
|
|
203
|
+
newTitle: newTitle || currentTitle,
|
|
204
|
+
changed: (newTitle !== currentTitle) && Boolean(newTitle),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Build video plans
|
|
210
|
+
const videoPlans = [];
|
|
211
|
+
const songsObj = rawMetadata?.songs || {};
|
|
212
|
+
const orderedKeys = Array.isArray(rawMetadata?.playlist?.orderedSongKeys)
|
|
213
|
+
? rawMetadata.playlist.orderedSongKeys
|
|
214
|
+
: Object.keys(songsObj);
|
|
215
|
+
|
|
216
|
+
const seenFiles = new Set();
|
|
217
|
+
|
|
218
|
+
if (options.singleVideoId) {
|
|
219
|
+
videoPlans.push({
|
|
220
|
+
filename: path.basename(resolvedFolder),
|
|
221
|
+
videoId: options.singleVideoId,
|
|
222
|
+
oldTitle: '',
|
|
223
|
+
newTitle: options.singleVideoTitle || '',
|
|
224
|
+
changed: true,
|
|
225
|
+
});
|
|
226
|
+
} else {
|
|
227
|
+
for (const filename of orderedKeys) {
|
|
228
|
+
seenFiles.add(filename);
|
|
229
|
+
const songData = songsObj[filename] || {};
|
|
230
|
+
const uploadRecord = state.getUpload(filename);
|
|
231
|
+
const videoId = uploadRecord?.videoId || songData.playlistItem?.videoId;
|
|
232
|
+
if (!videoId) continue;
|
|
233
|
+
|
|
234
|
+
let currentTitle = songData.youtube?.snippet?.title || path.basename(filename, path.extname(filename));
|
|
235
|
+
let newTitle = currentTitle;
|
|
236
|
+
|
|
237
|
+
if (options.syncMetadata) {
|
|
238
|
+
newTitle = currentTitle;
|
|
239
|
+
} else {
|
|
240
|
+
if (options.titlePostfix) {
|
|
241
|
+
if (!currentTitle.endsWith(options.titlePostfix)) {
|
|
242
|
+
newTitle = currentTitle + options.titlePostfix;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (options.titlePrefix) {
|
|
246
|
+
if (!newTitle.startsWith(options.titlePrefix)) {
|
|
247
|
+
newTitle = options.titlePrefix + newTitle;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (options.replacePattern) {
|
|
253
|
+
const reg = new RegExp(options.replacePattern, 'g');
|
|
254
|
+
newTitle = newTitle.replace(reg, options.replaceWith || '');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
videoPlans.push({
|
|
258
|
+
filename,
|
|
259
|
+
videoId,
|
|
260
|
+
oldTitle: currentTitle,
|
|
261
|
+
newTitle,
|
|
262
|
+
changed: (newTitle !== currentTitle),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check any additional uploads in state file
|
|
267
|
+
const stateUploads = state.listUploads();
|
|
268
|
+
for (const upload of stateUploads) {
|
|
269
|
+
if (!seenFiles.has(upload.filename) && upload.videoId) {
|
|
270
|
+
let currentTitle = path.basename(upload.filename, path.extname(upload.filename));
|
|
271
|
+
let newTitle = currentTitle;
|
|
272
|
+
if (!options.syncMetadata) {
|
|
273
|
+
if (options.titlePostfix && !currentTitle.endsWith(options.titlePostfix)) {
|
|
274
|
+
newTitle = currentTitle + options.titlePostfix;
|
|
275
|
+
}
|
|
276
|
+
if (options.titlePrefix && !newTitle.startsWith(options.titlePrefix)) {
|
|
277
|
+
newTitle = options.titlePrefix + newTitle;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (options.replacePattern) {
|
|
281
|
+
const reg = new RegExp(options.replacePattern, 'g');
|
|
282
|
+
newTitle = newTitle.replace(reg, options.replaceWith || '');
|
|
283
|
+
}
|
|
284
|
+
videoPlans.push({
|
|
285
|
+
filename: upload.filename,
|
|
286
|
+
videoId: upload.videoId,
|
|
287
|
+
oldTitle: currentTitle,
|
|
288
|
+
newTitle,
|
|
289
|
+
changed: (newTitle !== currentTitle),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const changedVideos = videoPlans.filter(v => v.changed);
|
|
296
|
+
const playlistChanged = Boolean(playlistPlan && playlistPlan.changed);
|
|
297
|
+
const estimatedQuota = (changedVideos.length * (QUOTA_PER_VIDEO_UPDATE + QUOTA_PER_LIST)) +
|
|
298
|
+
(playlistChanged ? (QUOTA_PER_PLAYLIST_UPDATE + QUOTA_PER_LIST) : 0);
|
|
299
|
+
|
|
300
|
+
onEvent({
|
|
301
|
+
type: 'update_plan',
|
|
302
|
+
playlist: playlistPlan,
|
|
303
|
+
videos: videoPlans,
|
|
304
|
+
changedCount: changedVideos.length + (playlistChanged ? 1 : 0),
|
|
305
|
+
estimatedQuota,
|
|
306
|
+
dryRun,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
if (dryRun) {
|
|
310
|
+
return {
|
|
311
|
+
playlist: playlistPlan,
|
|
312
|
+
videos: videoPlans,
|
|
313
|
+
estimatedQuota,
|
|
314
|
+
dryRun: true,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (changedVideos.length === 0 && !playlistChanged) {
|
|
319
|
+
return {
|
|
320
|
+
playlist: playlistPlan,
|
|
321
|
+
videos: videoPlans,
|
|
322
|
+
estimatedQuota: 0,
|
|
323
|
+
dryRun: false,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Authenticate
|
|
328
|
+
const auth = await authenticate({
|
|
329
|
+
tokenFile: options.tokenFile,
|
|
330
|
+
clientSecretsFile: options.clientSecretsFile,
|
|
331
|
+
clientId: options.clientId,
|
|
332
|
+
clientSecret: options.clientSecret,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const youtube = google.youtube({ version: 'v3', auth });
|
|
336
|
+
|
|
337
|
+
// Update Playlist
|
|
338
|
+
if (playlistPlan && playlistPlan.changed) {
|
|
339
|
+
onEvent({ type: 'playlist_update_start', playlistId: playlistPlan.playlistId, newTitle: playlistPlan.newTitle });
|
|
340
|
+
try {
|
|
341
|
+
await updatePlaylistTitle(youtube, playlistPlan.playlistId, playlistPlan.newTitle);
|
|
342
|
+
onEvent({ type: 'playlist_update_success', playlistId: playlistPlan.playlistId, newTitle: playlistPlan.newTitle });
|
|
343
|
+
} catch (err) {
|
|
344
|
+
onEvent({ type: 'playlist_update_error', playlistId: playlistPlan.playlistId, error: err });
|
|
345
|
+
throw new Error(`Failed to update playlist title: ${err.message}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Update Videos
|
|
350
|
+
for (let i = 0; i < changedVideos.length; i++) {
|
|
351
|
+
const v = changedVideos[i];
|
|
352
|
+
onEvent({ type: 'video_update_start', item: v, index: i, total: changedVideos.length });
|
|
353
|
+
try {
|
|
354
|
+
await updateVideoTitle(youtube, v.videoId, v.newTitle);
|
|
355
|
+
onEvent({ type: 'video_update_success', item: v, index: i, total: changedVideos.length });
|
|
356
|
+
} catch (err) {
|
|
357
|
+
onEvent({ type: 'video_update_error', item: v, error: err });
|
|
358
|
+
throw new Error(`Failed to update video "${v.filename}" (${v.videoId}): ${err.message}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Update metadata.json on disk if present and requested
|
|
363
|
+
if (updateLocal && rawMetadata && fs.existsSync(metadataPath)) {
|
|
364
|
+
try {
|
|
365
|
+
if (playlistPlan && playlistPlan.changed) {
|
|
366
|
+
if (rawMetadata.playlist?.youtube?.snippet) {
|
|
367
|
+
rawMetadata.playlist.youtube.snippet.title = playlistPlan.newTitle;
|
|
368
|
+
} else if (rawMetadata.playlist?.snippet) {
|
|
369
|
+
rawMetadata.playlist.snippet.title = playlistPlan.newTitle;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const v of videoPlans) {
|
|
374
|
+
if (v.changed && rawMetadata.songs?.[v.filename]?.youtube?.snippet) {
|
|
375
|
+
rawMetadata.songs[v.filename].youtube.snippet.title = v.newTitle;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
fs.writeFileSync(metadataPath, JSON.stringify(rawMetadata, null, 2) + '\n', 'utf-8');
|
|
380
|
+
onEvent({ type: 'metadata_file_saved', metadataPath });
|
|
381
|
+
} catch (err) {
|
|
382
|
+
onEvent({ type: 'metadata_file_error', error: err });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
playlist: playlistPlan,
|
|
388
|
+
videos: videoPlans,
|
|
389
|
+
estimatedQuota,
|
|
390
|
+
dryRun: false,
|
|
391
|
+
};
|
|
392
|
+
}
|
package/src/uploader.js
CHANGED
|
@@ -4,6 +4,7 @@ import { google } from 'googleapis';
|
|
|
4
4
|
import { loadMetadata } from './metadata.js';
|
|
5
5
|
import { UploadState } from './state.js';
|
|
6
6
|
import { authenticate } from './auth.js';
|
|
7
|
+
import { resolveMediaForMetadata } from './media/resolve.js';
|
|
7
8
|
|
|
8
9
|
export const QUOTA_PER_VIDEO_UPLOAD = 1600;
|
|
9
10
|
export const QUOTA_PER_PLAYLIST_ITEM = 50;
|
|
@@ -134,6 +135,9 @@ export async function addToPlaylist(youtube, playlistId, videoId, position) {
|
|
|
134
135
|
* @param {string} [options.metadataFile] - Custom metadata JSON path
|
|
135
136
|
* @param {boolean} [options.force=false] - Re-upload previously uploaded videos
|
|
136
137
|
* @param {boolean} [options.dryRun=false] - Preview without uploading
|
|
138
|
+
* @param {number} [options.limit] - Max videos to upload in this run
|
|
139
|
+
* @param {boolean} [options.safeQuota=false] - Cap batch under ~9,950 daily units
|
|
140
|
+
* @param {number} [options.maxQuota] - Hard quota unit cap for this run
|
|
137
141
|
* @param {string} [options.tokenFile] - Custom token JSON path
|
|
138
142
|
* @param {string} [options.clientSecretsFile] - Custom client_secret.json path
|
|
139
143
|
* @param {(event: { type: string, [key: string]: any }) => void} [options.onEvent]
|
|
@@ -142,6 +146,7 @@ export async function addToPlaylist(youtube, playlistId, videoId, position) {
|
|
|
142
146
|
* total: number,
|
|
143
147
|
* uploaded: Array<{ filename: string, videoId: string, url: string, addedToPlaylist: boolean }>,
|
|
144
148
|
* skipped: Array<{ filename: string, videoId?: string, reason: string }>,
|
|
149
|
+
* deferred: Array<{ filename: string, reason: string }>,
|
|
145
150
|
* failed: Array<{ filename: string, error: Error }>,
|
|
146
151
|
* estimatedQuota: number,
|
|
147
152
|
* playlistId?: string | null
|
|
@@ -153,6 +158,20 @@ export async function batchUpload(targetFolder, options = {}) {
|
|
|
153
158
|
const dryRun = Boolean(options.dryRun);
|
|
154
159
|
const onEvent = options.onEvent || (() => {});
|
|
155
160
|
|
|
161
|
+
if (options.autoMedia) {
|
|
162
|
+
onEvent({ type: 'auto_media_start' });
|
|
163
|
+
await resolveMediaForMetadata(resolvedFolder, {
|
|
164
|
+
compose: true,
|
|
165
|
+
provider: options.mediaProvider,
|
|
166
|
+
force: options.force,
|
|
167
|
+
dryRun: options.dryRun,
|
|
168
|
+
pexelsKey: options.pexelsKey,
|
|
169
|
+
pixabayKey: options.pixabayKey,
|
|
170
|
+
onEvent,
|
|
171
|
+
});
|
|
172
|
+
onEvent({ type: 'auto_media_complete' });
|
|
173
|
+
}
|
|
174
|
+
|
|
156
175
|
const config = loadMetadata(resolvedFolder, options.metadataFile);
|
|
157
176
|
const state = new UploadState(resolvedFolder);
|
|
158
177
|
|
|
@@ -369,6 +388,7 @@ export async function batchUpload(targetFolder, options = {}) {
|
|
|
369
388
|
total: config.videos.length,
|
|
370
389
|
uploaded,
|
|
371
390
|
skipped,
|
|
391
|
+
deferred,
|
|
372
392
|
failed,
|
|
373
393
|
estimatedQuota,
|
|
374
394
|
playlistId,
|