@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.
@@ -0,0 +1,235 @@
1
+ import pLimit from 'p-limit';
2
+ import { loadDotEnv } from '../auth.js';
3
+ import { searchPexels } from './pexels.js';
4
+ import { searchPixabay } from './pixabay.js';
5
+ import {
6
+ createSearchCacheKey,
7
+ getCachedSearch,
8
+ setCachedSearch,
9
+ DEFAULT_MEDIA_CACHE_DIR,
10
+ } from '../media/cache.js';
11
+
12
+ export { searchPexels } from './pexels.js';
13
+ export { searchPixabay } from './pixabay.js';
14
+
15
+ /**
16
+ * Global provider request limiters to avoid bursting.
17
+ */
18
+ const providerLimit = pLimit(3);
19
+
20
+ /**
21
+ * Sleep helper for backoff.
22
+ * @param {number} ms
23
+ * @returns {Promise<void>}
24
+ */
25
+ function sleep(ms) {
26
+ return new Promise((resolve) => setTimeout(resolve, ms));
27
+ }
28
+
29
+ /**
30
+ * Resolve provider API keys and fallback orders from options and environment variables.
31
+ * @param {object} [options]
32
+ * @returns {{
33
+ * pexelsKey?: string,
34
+ * pixabayKey?: string,
35
+ * providerOrder: Array<'pexels' | 'pixabay'>
36
+ * }}
37
+ */
38
+ export function resolveProviderCredentials(options = {}) {
39
+ loadDotEnv();
40
+
41
+ const rawPexels = options.pexelsKey !== undefined ? options.pexelsKey : (process.env.PEXELS_KEY || process.env.PEXEL_KEY);
42
+ const rawPixabay = options.pixabayKey !== undefined ? options.pixabayKey : (process.env.PIXABAY_KEY || process.env.PICABAY_KEY);
43
+
44
+ const pexelsKey = rawPexels ? String(rawPexels).trim() : undefined;
45
+ const pixabayKey = rawPixabay ? String(rawPixabay).trim() : undefined;
46
+
47
+ let providerOrder = ['pexels', 'pixabay'];
48
+ if (Array.isArray(options.providerOrder) && options.providerOrder.length > 0) {
49
+ providerOrder = options.providerOrder;
50
+ } else if (process.env.MEDIA_PROVIDER_ORDER) {
51
+ const parsed = process.env.MEDIA_PROVIDER_ORDER.split(',')
52
+ .map(s => s.trim().toLowerCase())
53
+ .filter(s => s === 'pexels' || s === 'pixabay');
54
+ if (parsed.length > 0) {
55
+ providerOrder = parsed;
56
+ }
57
+ }
58
+
59
+ return {
60
+ pexelsKey,
61
+ pixabayKey,
62
+ providerOrder,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Execute a provider search with exponential backoff on HTTP 429.
68
+ * @param {() => Promise<any[]>} searchFn
69
+ * @param {number} [maxAttempts=3]
70
+ * @returns {Promise<any[]>}
71
+ */
72
+ async function executeWithRetry(searchFn, maxAttempts = 3) {
73
+ let attempt = 0;
74
+ let delayMs = 1000;
75
+
76
+ while (attempt < maxAttempts) {
77
+ attempt++;
78
+ try {
79
+ return await providerLimit(() => searchFn());
80
+ } catch (err) {
81
+ if (err.status === 429 && attempt < maxAttempts) {
82
+ const waitTime = (err.retryAfter ? err.retryAfter * 1000 : delayMs) + Math.floor(Math.random() * 500);
83
+ await sleep(waitTime);
84
+ delayMs *= 2;
85
+ continue;
86
+ }
87
+ throw err;
88
+ }
89
+ }
90
+ return [];
91
+ }
92
+
93
+ /**
94
+ * Unified search across media providers with caching and fallback / merge strategies.
95
+ *
96
+ * @param {object} options
97
+ * @param {string} options.query
98
+ * @param {'video' | 'photo'} [options.type='video']
99
+ * @param {'auto' | 'pexels' | 'pixabay'} [options.provider='auto']
100
+ * @param {'fallback' | 'merge'} [options.mergeStrategy='fallback']
101
+ * @param {Array<'pexels' | 'pixabay'>} [options.providerOrder]
102
+ * @param {'landscape' | 'portrait' | 'square'} [options.orientation]
103
+ * @param {number} [options.page=1]
104
+ * @param {number} [options.perPage=20]
105
+ * @param {boolean} [options.safeSearch=true]
106
+ * @param {string} [options.pexelsKey]
107
+ * @param {string} [options.pixabayKey]
108
+ * @param {boolean} [options.useCache=true]
109
+ * @param {string} [options.cacheDir=DEFAULT_MEDIA_CACHE_DIR]
110
+ * @param {typeof fetch} [options.fetchFn=fetch]
111
+ * @returns {Promise<import('./index.js').UnifiedResult[]>}
112
+ */
113
+ export async function searchMedia(options) {
114
+ const {
115
+ query,
116
+ type = 'video',
117
+ provider = 'auto',
118
+ mergeStrategy = 'fallback',
119
+ orientation,
120
+ page = 1,
121
+ perPage = 20,
122
+ safeSearch = true,
123
+ useCache = true,
124
+ cacheDir = DEFAULT_MEDIA_CACHE_DIR,
125
+ fetchFn = fetch,
126
+ } = options || {};
127
+
128
+ if (!query || typeof query !== 'string' || !query.trim()) {
129
+ return [];
130
+ }
131
+
132
+ const credentials = resolveProviderCredentials(options);
133
+
134
+ // Determine target providers
135
+ let targetProviders = [];
136
+ if (provider === 'pexels') {
137
+ targetProviders = ['pexels'];
138
+ } else if (provider === 'pixabay') {
139
+ targetProviders = ['pixabay'];
140
+ } else {
141
+ targetProviders = credentials.providerOrder;
142
+ }
143
+
144
+ const availableProviders = targetProviders.filter(p => {
145
+ if (p === 'pexels') return Boolean(credentials.pexelsKey);
146
+ if (p === 'pixabay') return Boolean(credentials.pixabayKey);
147
+ return false;
148
+ });
149
+
150
+ if (availableProviders.length === 0) {
151
+ throw new Error(
152
+ `No media provider API keys configured for target provider "${provider}".\n` +
153
+ `Please provide PEXELS_KEY or PIXABAY_KEY in your environment or via CLI options.`
154
+ );
155
+ }
156
+
157
+ const performProviderSearch = async (p) => {
158
+ const cacheKey = createSearchCacheKey({
159
+ provider: p,
160
+ type,
161
+ query,
162
+ orientation,
163
+ });
164
+
165
+ if (useCache) {
166
+ const cached = getCachedSearch(cacheDir, cacheKey);
167
+ if (cached) {
168
+ return cached;
169
+ }
170
+ }
171
+
172
+ let results = [];
173
+ if (p === 'pexels') {
174
+ results = await executeWithRetry(() => searchPexels({
175
+ query,
176
+ type,
177
+ orientation,
178
+ page,
179
+ perPage,
180
+ apiKey: credentials.pexelsKey,
181
+ fetchFn,
182
+ }));
183
+ } else if (p === 'pixabay') {
184
+ results = await executeWithRetry(() => searchPixabay({
185
+ query,
186
+ type,
187
+ orientation,
188
+ page,
189
+ perPage,
190
+ safeSearch,
191
+ apiKey: credentials.pixabayKey,
192
+ fetchFn,
193
+ }));
194
+ }
195
+
196
+ if (useCache && results.length > 0) {
197
+ setCachedSearch(cacheDir, cacheKey, results);
198
+ }
199
+
200
+ return results;
201
+ };
202
+
203
+ if (mergeStrategy === 'merge') {
204
+ const allResults = [];
205
+ for (const p of availableProviders) {
206
+ try {
207
+ const results = await performProviderSearch(p);
208
+ allResults.push(...results);
209
+ } catch (err) {
210
+ // In merge mode, if one fails, continue with others
211
+ }
212
+ }
213
+ return allResults;
214
+ }
215
+
216
+ // Fallback strategy (default)
217
+ let lastError = null;
218
+ for (const p of availableProviders) {
219
+ try {
220
+ const results = await performProviderSearch(p);
221
+ if (results.length > 0) {
222
+ return results;
223
+ }
224
+ } catch (err) {
225
+ lastError = err;
226
+ // Continue to next provider on error
227
+ }
228
+ }
229
+
230
+ if (lastError && availableProviders.length === 1) {
231
+ throw lastError;
232
+ }
233
+
234
+ return [];
235
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Pexels API Adapter
3
+ */
4
+
5
+ /**
6
+ * Determine orientation from dimensions.
7
+ * @param {number} width
8
+ * @param {number} height
9
+ * @returns {'landscape' | 'portrait' | 'square'}
10
+ */
11
+ function getOrientation(width, height) {
12
+ if (width > height) return 'landscape';
13
+ if (height > width) return 'portrait';
14
+ return 'square';
15
+ }
16
+
17
+ /**
18
+ * Search Pexels for photos or videos and return UnifiedResult[]
19
+ *
20
+ * @param {object} options
21
+ * @param {string} options.query
22
+ * @param {'video' | 'photo'} [options.type='video']
23
+ * @param {'landscape' | 'portrait' | 'square'} [options.orientation]
24
+ * @param {number} [options.page=1]
25
+ * @param {number} [options.perPage=20]
26
+ * @param {string} options.apiKey
27
+ * @param {typeof fetch} [options.fetchFn=fetch]
28
+ * @returns {Promise<import('./index.js').UnifiedResult[]>}
29
+ */
30
+ export async function searchPexels({
31
+ query,
32
+ type = 'video',
33
+ orientation,
34
+ page = 1,
35
+ perPage = 20,
36
+ apiKey,
37
+ fetchFn = fetch,
38
+ }) {
39
+ if (!apiKey) {
40
+ throw new Error('Pexels API key is missing. Set PEXELS_KEY or pass --pexels-key.');
41
+ }
42
+ if (!query || typeof query !== 'string' || !query.trim()) {
43
+ return [];
44
+ }
45
+
46
+ const isVideo = type === 'video';
47
+ const endpoint = isVideo
48
+ ? 'https://api.pexels.com/videos/search'
49
+ : 'https://api.pexels.com/v1/search';
50
+
51
+ const url = new URL(endpoint);
52
+ url.searchParams.set('query', query.trim());
53
+ url.searchParams.set('page', String(page));
54
+ url.searchParams.set('per_page', String(Math.min(80, Math.max(1, perPage))));
55
+ if (orientation) {
56
+ url.searchParams.set('orientation', orientation);
57
+ }
58
+
59
+ const res = await fetchFn(url.toString(), {
60
+ method: 'GET',
61
+ headers: {
62
+ Authorization: apiKey,
63
+ 'User-Agent': 'youtube-video-uploader-cli',
64
+ },
65
+ });
66
+
67
+ if (res.status === 401 || res.status === 403) {
68
+ const text = await res.text().catch(() => '');
69
+ const err = new Error(`Pexels API Authentication Failed (${res.status}): ${text || 'Invalid API Key'}`);
70
+ err.status = res.status;
71
+ throw err;
72
+ }
73
+
74
+ if (res.status === 429) {
75
+ const retryAfter = res.headers.get('retry-after');
76
+ const err = new Error(`Pexels API Rate Limit Exceeded (429)`);
77
+ err.status = 429;
78
+ err.retryAfter = retryAfter ? parseInt(retryAfter, 10) : null;
79
+ throw err;
80
+ }
81
+
82
+ if (!res.ok) {
83
+ const text = await res.text().catch(() => '');
84
+ const err = new Error(`Pexels API error (${res.status}): ${text}`);
85
+ err.status = res.status;
86
+ throw err;
87
+ }
88
+
89
+ const data = await res.json();
90
+ const items = isVideo ? (data.videos || []) : (data.photos || []);
91
+
92
+ const results = items.map((item, index) => {
93
+ if (isVideo) {
94
+ // Find best video file (prefer 1920x1080 or highest resolution MP4)
95
+ const videoFiles = Array.isArray(item.video_files) ? item.video_files : [];
96
+ const mp4Files = videoFiles.filter(f => !f.file_type || f.file_type === 'video/mp4' || f.link?.includes('.mp4'));
97
+ const candidateFiles = mp4Files.length > 0 ? mp4Files : videoFiles;
98
+
99
+ // Sort by resolution descending
100
+ candidateFiles.sort((a, b) => {
101
+ const resA = (a.width || 0) * (a.height || 0);
102
+ const resB = (b.width || 0) * (b.height || 0);
103
+ return resB - resA;
104
+ });
105
+
106
+ // Try to find HD file closest to 1920x1080, else highest available
107
+ const hdFile = candidateFiles.find(f => f.quality === 'hd' && f.width >= 1280) || candidateFiles[0];
108
+ const chosenFile = hdFile || candidateFiles[0] || {};
109
+
110
+ const width = chosenFile.width || item.width || 1920;
111
+ const height = chosenFile.height || item.height || 1080;
112
+ const calculatedOrientation = getOrientation(width, height);
113
+
114
+ const previewUrl = item.video_pictures?.[0]?.picture || item.image || '';
115
+ const tags = Array.isArray(item.tags)
116
+ ? item.tags.map(t => (typeof t === 'string' ? t : t.name)).filter(Boolean)
117
+ : [];
118
+
119
+ return {
120
+ provider: 'pexels',
121
+ id: String(item.id),
122
+ type: 'video',
123
+ rank: index,
124
+ pageUrl: item.url || `https://www.pexels.com/video/${item.id}/`,
125
+ previewUrl,
126
+ downloadUrl: chosenFile.link || '',
127
+ width,
128
+ height,
129
+ duration: typeof item.duration === 'number' ? item.duration : undefined,
130
+ orientation: calculatedOrientation,
131
+ tags,
132
+ creator: {
133
+ name: item.user?.name || 'Pexels Creator',
134
+ profileUrl: item.user?.url || undefined,
135
+ },
136
+ license: 'Pexels License',
137
+ raw: item,
138
+ };
139
+ } else {
140
+ // Photo
141
+ const width = item.width || 1920;
142
+ const height = item.height || 1080;
143
+ const calculatedOrientation = getOrientation(width, height);
144
+ const downloadUrl = item.src?.original || item.src?.large2x || item.src?.large || '';
145
+ const previewUrl = item.src?.small || item.src?.tiny || item.src?.medium || downloadUrl;
146
+ const tags = item.alt ? item.alt.split(/[,;\s]+/).filter(Boolean) : [];
147
+
148
+ return {
149
+ provider: 'pexels',
150
+ id: String(item.id),
151
+ type: 'photo',
152
+ rank: index,
153
+ pageUrl: item.url || `https://www.pexels.com/photo/${item.id}/`,
154
+ previewUrl,
155
+ downloadUrl,
156
+ width,
157
+ height,
158
+ orientation: calculatedOrientation,
159
+ tags,
160
+ creator: {
161
+ name: item.photographer || 'Pexels Photographer',
162
+ profileUrl: item.photographer_url || undefined,
163
+ },
164
+ license: 'Pexels License',
165
+ raw: item,
166
+ };
167
+ }
168
+ });
169
+
170
+ return results;
171
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Pixabay API Adapter
3
+ */
4
+
5
+ /**
6
+ * Determine orientation from dimensions.
7
+ * @param {number} width
8
+ * @param {number} height
9
+ * @returns {'landscape' | 'portrait' | 'square'}
10
+ */
11
+ function getOrientation(width, height) {
12
+ if (width > height) return 'landscape';
13
+ if (height > width) return 'portrait';
14
+ return 'square';
15
+ }
16
+
17
+ /**
18
+ * Map generic orientation to Pixabay orientation filter
19
+ * @param {'landscape' | 'portrait' | 'square'} [orientation]
20
+ * @returns {string | undefined}
21
+ */
22
+ function mapPixabayOrientation(orientation) {
23
+ if (orientation === 'landscape') return 'horizontal';
24
+ if (orientation === 'portrait') return 'vertical';
25
+ return undefined;
26
+ }
27
+
28
+ /**
29
+ * Search Pixabay for photos or videos and return UnifiedResult[]
30
+ *
31
+ * @param {object} options
32
+ * @param {string} options.query
33
+ * @param {'video' | 'photo'} [options.type='video']
34
+ * @param {'landscape' | 'portrait' | 'square'} [options.orientation]
35
+ * @param {number} [options.page=1]
36
+ * @param {number} [options.perPage=20]
37
+ * @param {boolean} [options.safeSearch=true]
38
+ * @param {string} options.apiKey
39
+ * @param {typeof fetch} [options.fetchFn=fetch]
40
+ * @returns {Promise<import('./index.js').UnifiedResult[]>}
41
+ */
42
+ export async function searchPixabay({
43
+ query,
44
+ type = 'video',
45
+ orientation,
46
+ page = 1,
47
+ perPage = 20,
48
+ safeSearch = true,
49
+ apiKey,
50
+ fetchFn = fetch,
51
+ }) {
52
+ if (!apiKey) {
53
+ throw new Error('Pixabay API key is missing. Set PIXABAY_KEY or pass --pixabay-key.');
54
+ }
55
+ if (!query || typeof query !== 'string' || !query.trim()) {
56
+ return [];
57
+ }
58
+
59
+ const isVideo = type === 'video';
60
+ const endpoint = isVideo
61
+ ? 'https://pixabay.com/api/videos/'
62
+ : 'https://pixabay.com/api/';
63
+
64
+ const url = new URL(endpoint);
65
+ url.searchParams.set('key', apiKey);
66
+ url.searchParams.set('q', query.trim());
67
+ url.searchParams.set('page', String(page));
68
+ url.searchParams.set('per_page', String(Math.min(200, Math.max(3, perPage))));
69
+ url.searchParams.set('safesearch', safeSearch ? 'true' : 'false');
70
+
71
+ const pixabayOrientation = mapPixabayOrientation(orientation);
72
+ if (pixabayOrientation) {
73
+ url.searchParams.set('orientation', pixabayOrientation);
74
+ }
75
+
76
+ if (!isVideo) {
77
+ url.searchParams.set('image_type', 'photo');
78
+ }
79
+
80
+ const res = await fetchFn(url.toString(), {
81
+ method: 'GET',
82
+ headers: {
83
+ 'User-Agent': 'youtube-video-uploader-cli',
84
+ },
85
+ });
86
+
87
+ if (res.status === 400 || res.status === 401 || res.status === 403) {
88
+ const text = await res.text().catch(() => '');
89
+ if (text.includes('Invalid') || text.includes('key') || res.status === 401 || res.status === 403) {
90
+ const err = new Error(`Pixabay API Authentication Failed (${res.status}): ${text || 'Invalid API Key'}`);
91
+ err.status = res.status;
92
+ throw err;
93
+ }
94
+ }
95
+
96
+ if (res.status === 429) {
97
+ const retryAfter = res.headers.get('retry-after');
98
+ const err = new Error(`Pixabay API Rate Limit Exceeded (429)`);
99
+ err.status = 429;
100
+ err.retryAfter = retryAfter ? parseInt(retryAfter, 10) : null;
101
+ throw err;
102
+ }
103
+
104
+ if (!res.ok) {
105
+ const text = await res.text().catch(() => '');
106
+ const err = new Error(`Pixabay API error (${res.status}): ${text}`);
107
+ err.status = res.status;
108
+ throw err;
109
+ }
110
+
111
+ const data = await res.json();
112
+ const items = Array.isArray(data.hits) ? data.hits : [];
113
+
114
+ const results = items.map((item, index) => {
115
+ const tags = typeof item.tags === 'string'
116
+ ? item.tags.split(',').map(t => t.trim()).filter(Boolean)
117
+ : [];
118
+
119
+ if (isVideo) {
120
+ const videos = item.videos || {};
121
+ const chosenVideo = videos.large?.url ? videos.large :
122
+ videos.medium?.url ? videos.medium :
123
+ videos.small?.url ? videos.small :
124
+ videos.tiny || {};
125
+
126
+ const width = chosenVideo.width || 1920;
127
+ const height = chosenVideo.height || 1080;
128
+ const calculatedOrientation = getOrientation(width, height);
129
+
130
+ const previewUrl = item.picture_id
131
+ ? `https://i.vimeocdn.com/video/${item.picture_id}_640x360.jpg`
132
+ : (item.userImageURL || '');
133
+
134
+ return {
135
+ provider: 'pixabay',
136
+ id: String(item.id),
137
+ type: 'video',
138
+ rank: index,
139
+ pageUrl: item.pageURL || `https://pixabay.com/videos/id-${item.id}/`,
140
+ previewUrl,
141
+ downloadUrl: chosenVideo.url || '',
142
+ width,
143
+ height,
144
+ duration: typeof item.duration === 'number' ? item.duration : undefined,
145
+ orientation: calculatedOrientation,
146
+ tags,
147
+ creator: {
148
+ name: item.user || 'Pixabay Creator',
149
+ profileUrl: item.user_id ? `https://pixabay.com/users/${item.user}-${item.user_id}/` : undefined,
150
+ },
151
+ license: 'Pixabay Content License',
152
+ raw: item,
153
+ };
154
+ } else {
155
+ const width = item.imageWidth || 1920;
156
+ const height = item.imageHeight || 1080;
157
+ const calculatedOrientation = getOrientation(width, height);
158
+ const downloadUrl = item.largeImageURL || item.fullHDURL || item.imageURL || item.webformatURL || '';
159
+ const previewUrl = item.previewURL || item.webformatURL || downloadUrl;
160
+
161
+ return {
162
+ provider: 'pixabay',
163
+ id: String(item.id),
164
+ type: 'photo',
165
+ rank: index,
166
+ pageUrl: item.pageURL || `https://pixabay.com/photos/id-${item.id}/`,
167
+ previewUrl,
168
+ downloadUrl,
169
+ width,
170
+ height,
171
+ orientation: calculatedOrientation,
172
+ tags,
173
+ creator: {
174
+ name: item.user || 'Pixabay Photographer',
175
+ profileUrl: item.user_id ? `https://pixabay.com/users/${item.user}-${item.user_id}/` : undefined,
176
+ },
177
+ license: 'Pixabay Content License',
178
+ raw: item,
179
+ };
180
+ }
181
+ });
182
+
183
+ return results;
184
+ }
package/src/state.js CHANGED
@@ -37,6 +37,17 @@ export class UploadState {
37
37
  }
38
38
  }
39
39
 
40
+ /**
41
+ * List all upload records.
42
+ * @returns {Array<{ filename: string, videoId: string, uploadedAt: string, playlistId?: string }>}
43
+ */
44
+ listUploads() {
45
+ return Object.entries(this.data.uploads || {}).map(([filename, record]) => ({
46
+ filename,
47
+ ...record,
48
+ }));
49
+ }
50
+
40
51
  /**
41
52
  * Check if a file has already been uploaded.
42
53
  * @param {string} filename