@x12i/youtube-video-uploader-cli 1.2.0 → 1.4.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 +351 -27
- package/bin/cli.js +620 -2
- package/package.json +7 -1
- package/src/actions/index.js +276 -0
- package/src/actions/journey-overlay.js +623 -0
- package/src/actions/replace-background.js +192 -0
- package/src/actions/shorten.js +310 -0
- package/src/batch/clone-metadata.js +51 -0
- package/src/batch/plan.js +195 -0
- package/src/cli/apply-actions.js +152 -0
- package/src/cli/preview-journey.js +111 -0
- package/src/index.d.ts +532 -3
- package/src/index.js +82 -1
- package/src/media/cache.js +134 -0
- package/src/media/compose.js +300 -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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_MEDIA_CACHE_DIR = '.media-cache';
|
|
5
|
+
export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate normalized cache key for media searches.
|
|
9
|
+
* @param {object} params
|
|
10
|
+
* @param {string} params.provider
|
|
11
|
+
* @param {string} params.type
|
|
12
|
+
* @param {string} params.query
|
|
13
|
+
* @param {string} [params.orientation]
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function createSearchCacheKey({ provider, type, query, orientation }) {
|
|
17
|
+
const normalizedQuery = (query || '').toLowerCase().trim().replace(/\s+/g, ' ');
|
|
18
|
+
const normOrientation = orientation || 'any';
|
|
19
|
+
return `${provider}:${type}:${normalizedQuery}:${normOrientation}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read the search cache file if it exists.
|
|
24
|
+
* @param {string} [cacheDir=DEFAULT_MEDIA_CACHE_DIR]
|
|
25
|
+
* @returns {Record<string, { timestamp: number, results: any[] }>}
|
|
26
|
+
*/
|
|
27
|
+
export function readSearchCache(cacheDir = DEFAULT_MEDIA_CACHE_DIR) {
|
|
28
|
+
const filePath = path.join(cacheDir, 'search-cache.json');
|
|
29
|
+
if (!fs.existsSync(filePath)) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
34
|
+
return JSON.parse(raw);
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get cached search results if valid and not expired.
|
|
42
|
+
* @param {string} cacheDir
|
|
43
|
+
* @param {string} cacheKey
|
|
44
|
+
* @param {number} [ttlMs=CACHE_TTL_MS]
|
|
45
|
+
* @returns {any[] | null}
|
|
46
|
+
*/
|
|
47
|
+
export function getCachedSearch(cacheDir, cacheKey, ttlMs = CACHE_TTL_MS) {
|
|
48
|
+
const cache = readSearchCache(cacheDir);
|
|
49
|
+
const entry = cache[cacheKey];
|
|
50
|
+
if (!entry || !entry.timestamp || !Array.isArray(entry.results)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const isExpired = Date.now() - entry.timestamp > ttlMs;
|
|
54
|
+
if (isExpired) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return entry.results;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Store search results in search cache.
|
|
62
|
+
* @param {string} cacheDir
|
|
63
|
+
* @param {string} cacheKey
|
|
64
|
+
* @param {any[]} results
|
|
65
|
+
*/
|
|
66
|
+
export function setCachedSearch(cacheDir, cacheKey, results) {
|
|
67
|
+
const dir = path.resolve(cacheDir || DEFAULT_MEDIA_CACHE_DIR);
|
|
68
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
69
|
+
const filePath = path.join(dir, 'search-cache.json');
|
|
70
|
+
|
|
71
|
+
const cache = readSearchCache(dir);
|
|
72
|
+
cache[cacheKey] = {
|
|
73
|
+
timestamp: Date.now(),
|
|
74
|
+
results,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
fs.writeFileSync(filePath, JSON.stringify(cache, null, 2), 'utf-8');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read the used-assets ledger.
|
|
82
|
+
* @param {string} [cacheDir=DEFAULT_MEDIA_CACHE_DIR]
|
|
83
|
+
* @returns {Record<string, { provider: string, assetId: string, usedAt: string, songKey?: string }>}
|
|
84
|
+
*/
|
|
85
|
+
export function readUsedAssetsLedger(cacheDir = DEFAULT_MEDIA_CACHE_DIR) {
|
|
86
|
+
const filePath = path.join(cacheDir, 'used-assets.json');
|
|
87
|
+
if (!fs.existsSync(filePath)) {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
92
|
+
return JSON.parse(raw);
|
|
93
|
+
} catch {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if an asset has already been used.
|
|
100
|
+
* @param {string} cacheDir
|
|
101
|
+
* @param {string} provider
|
|
102
|
+
* @param {string} assetId
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
export function isAssetUsed(cacheDir, provider, assetId) {
|
|
106
|
+
const ledger = readUsedAssetsLedger(cacheDir);
|
|
107
|
+
const key = `${provider}:${assetId}`;
|
|
108
|
+
return Boolean(ledger[key]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Record an asset as used in the ledger.
|
|
113
|
+
* @param {string} cacheDir
|
|
114
|
+
* @param {object} assetInfo
|
|
115
|
+
* @param {string} assetInfo.provider
|
|
116
|
+
* @param {string} assetInfo.assetId
|
|
117
|
+
* @param {string} [assetInfo.songKey]
|
|
118
|
+
*/
|
|
119
|
+
export function recordUsedAsset(cacheDir, { provider, assetId, songKey }) {
|
|
120
|
+
const dir = path.resolve(cacheDir || DEFAULT_MEDIA_CACHE_DIR);
|
|
121
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
122
|
+
const filePath = path.join(dir, 'used-assets.json');
|
|
123
|
+
|
|
124
|
+
const ledger = readUsedAssetsLedger(dir);
|
|
125
|
+
const key = `${provider}:${assetId}`;
|
|
126
|
+
ledger[key] = {
|
|
127
|
+
provider,
|
|
128
|
+
assetId: String(assetId),
|
|
129
|
+
songKey: songKey || undefined,
|
|
130
|
+
usedAt: new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
fs.writeFileSync(filePath, JSON.stringify(ledger, null, 2), 'utf-8');
|
|
134
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import ffmpeg from 'fluent-ffmpeg';
|
|
2
|
+
import ffmpegStatic from 'ffmpeg-static';
|
|
3
|
+
import ffprobeStatic from 'ffprobe-static';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
|
|
8
|
+
// Configure ffmpeg and ffprobe paths if available
|
|
9
|
+
if (ffmpegStatic && fs.existsSync(ffmpegStatic)) {
|
|
10
|
+
ffmpeg.setFfmpegPath(ffmpegStatic);
|
|
11
|
+
}
|
|
12
|
+
if (ffprobeStatic?.path && fs.existsSync(ffprobeStatic.path)) {
|
|
13
|
+
ffmpeg.setFfprobePath(ffprobeStatic.path);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ASS_FILTER_UNSAFE = /['\[\]:;\\,]/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return a safe path to pass to ffmpeg's `ass=` filter.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} assPath
|
|
22
|
+
* @returns {{ filterPath: string | null, cleanupPath?: string }}
|
|
23
|
+
*/
|
|
24
|
+
export function resolveAssFilterPath(assPath) {
|
|
25
|
+
if (!assPath || !fs.existsSync(assPath)) {
|
|
26
|
+
return { filterPath: null };
|
|
27
|
+
}
|
|
28
|
+
if (!ASS_FILTER_UNSAFE.test(assPath)) {
|
|
29
|
+
return { filterPath: assPath };
|
|
30
|
+
}
|
|
31
|
+
const cleanupPath = path.join(
|
|
32
|
+
os.tmpdir(),
|
|
33
|
+
`compose-ass-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.ass`
|
|
34
|
+
);
|
|
35
|
+
try {
|
|
36
|
+
fs.copyFileSync(assPath, cleanupPath);
|
|
37
|
+
return { filterPath: cleanupPath, cleanupPath };
|
|
38
|
+
} catch {
|
|
39
|
+
return { filterPath: assPath };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Quote and escape special characters in path for ffmpeg filter syntax.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} filePath
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function escapeForFfmpeg(filePath) {
|
|
50
|
+
const escaped = String(filePath)
|
|
51
|
+
.replaceAll('\\', '\\\\')
|
|
52
|
+
.replaceAll(':', '\\:')
|
|
53
|
+
.replaceAll("'", "\\'");
|
|
54
|
+
return `'${escaped}'`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the duration of an audio or video file in seconds using ffprobe.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} filePath
|
|
61
|
+
* @returns {Promise<number>} Duration in seconds
|
|
62
|
+
*/
|
|
63
|
+
export function getMediaDuration(filePath) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
if (!fs.existsSync(filePath)) {
|
|
66
|
+
return reject(new Error(`File not found: ${filePath}`));
|
|
67
|
+
}
|
|
68
|
+
ffmpeg.ffprobe(filePath, (err, metadata) => {
|
|
69
|
+
if (err) {
|
|
70
|
+
return reject(new Error(`FFprobe error for ${filePath}: ${err.message}`));
|
|
71
|
+
}
|
|
72
|
+
const duration = metadata.format?.duration;
|
|
73
|
+
if (typeof duration === 'number') {
|
|
74
|
+
return resolve(duration);
|
|
75
|
+
}
|
|
76
|
+
if (typeof duration === 'string') {
|
|
77
|
+
const parsed = parseFloat(duration);
|
|
78
|
+
if (!isNaN(parsed)) {
|
|
79
|
+
return resolve(parsed);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Check streams if format duration is missing
|
|
83
|
+
const streamWithDuration = metadata.streams?.find(s => s.duration);
|
|
84
|
+
if (streamWithDuration?.duration) {
|
|
85
|
+
return resolve(parseFloat(streamWithDuration.duration));
|
|
86
|
+
}
|
|
87
|
+
reject(new Error(`Could not determine duration for: ${filePath}`));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compose a background video/photo and audio track into an MP4 file using ffmpeg.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} options
|
|
96
|
+
* @param {string} options.backgroundPath - Local path to background video or image
|
|
97
|
+
* @param {string} options.audioPath - Local path to audio track
|
|
98
|
+
* @param {string} options.outputPath - Destination path for final MP4
|
|
99
|
+
* @param {'video-loop' | 'video-trim' | 'ken-burns'} [options.mode='video-loop'] - Composition mode
|
|
100
|
+
* @param {number} [options.targetWidth=1920]
|
|
101
|
+
* @param {number} [options.targetHeight=1080]
|
|
102
|
+
* @param {number} [options.fps=30]
|
|
103
|
+
* @param {boolean} [options.dryRun=false]
|
|
104
|
+
* @param {(progress: { percent?: number, timemark?: string }) => void} [options.onProgress]
|
|
105
|
+
* @returns {Promise<{ outputPath: string, duration: number, dryRun?: boolean, command?: string }>}
|
|
106
|
+
*/
|
|
107
|
+
export async function composeVideo(options) {
|
|
108
|
+
const {
|
|
109
|
+
backgroundPath,
|
|
110
|
+
audioPath,
|
|
111
|
+
outputPath,
|
|
112
|
+
mode = 'video-loop',
|
|
113
|
+
targetWidth = 1920,
|
|
114
|
+
targetHeight = 1080,
|
|
115
|
+
fps = 30,
|
|
116
|
+
dryRun = false,
|
|
117
|
+
assPath = null,
|
|
118
|
+
onProgress,
|
|
119
|
+
} = options;
|
|
120
|
+
|
|
121
|
+
if (!fs.existsSync(backgroundPath)) {
|
|
122
|
+
throw new Error(`Background media file not found: ${backgroundPath}`);
|
|
123
|
+
}
|
|
124
|
+
if (!fs.existsSync(audioPath)) {
|
|
125
|
+
throw new Error(`Audio file not found: ${audioPath}`);
|
|
126
|
+
}
|
|
127
|
+
if (!outputPath) {
|
|
128
|
+
throw new Error('Output path is required for video composition.');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Ensure output directory exists
|
|
132
|
+
fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
133
|
+
|
|
134
|
+
const audioDuration = await getMediaDuration(audioPath);
|
|
135
|
+
let bgDuration = 0;
|
|
136
|
+
try {
|
|
137
|
+
bgDuration = await getMediaDuration(backgroundPath);
|
|
138
|
+
} catch {
|
|
139
|
+
// Might be static photo
|
|
140
|
+
bgDuration = 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const isImage = bgDuration === 0 || mode === 'ken-burns';
|
|
144
|
+
const baseScaleFilter = `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=increase,crop=${targetWidth}:${targetHeight},format=yuv420p`;
|
|
145
|
+
|
|
146
|
+
let assCleanupPath = null;
|
|
147
|
+
let scaleCropFilter = baseScaleFilter;
|
|
148
|
+
if (assPath && fs.existsSync(assPath)) {
|
|
149
|
+
const resolved = resolveAssFilterPath(assPath);
|
|
150
|
+
if (resolved.filterPath) {
|
|
151
|
+
assCleanupPath = resolved.cleanupPath || null;
|
|
152
|
+
scaleCropFilter = `${baseScaleFilter},ass=${escapeForFfmpeg(resolved.filterPath)}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cleanup = () => {
|
|
157
|
+
if (assCleanupPath && fs.existsSync(assCleanupPath)) {
|
|
158
|
+
try { fs.unlinkSync(assCleanupPath); } catch {}
|
|
159
|
+
assCleanupPath = null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (dryRun) {
|
|
164
|
+
cleanup();
|
|
165
|
+
const subsNote = assPath ? ` + subtitles (${path.basename(assPath)})` : '';
|
|
166
|
+
const planDescription = isImage
|
|
167
|
+
? `Image (${backgroundPath}) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s)${subsNote} -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`
|
|
168
|
+
: `Video (${backgroundPath}, ${bgDuration.toFixed(1)}s) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s)${subsNote} -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`;
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
outputPath,
|
|
172
|
+
duration: audioDuration,
|
|
173
|
+
dryRun: true,
|
|
174
|
+
command: planDescription,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
const command = ffmpeg();
|
|
180
|
+
|
|
181
|
+
const audioOpts = [
|
|
182
|
+
'-c:a', 'aac',
|
|
183
|
+
'-b:a', '320k',
|
|
184
|
+
'-ar', '48000',
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
if (isImage) {
|
|
188
|
+
if (mode === 'ken-burns') {
|
|
189
|
+
const totalFrames = Math.max(30, Math.ceil(audioDuration * fps));
|
|
190
|
+
const kenBurnsFilter = `scale=8000:-1,zoompan=z='min(zoom+0.0008,1.25)':d=${totalFrames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${targetWidth}x${targetHeight}:fps=${fps},${scaleCropFilter}`;
|
|
191
|
+
command
|
|
192
|
+
.input(backgroundPath)
|
|
193
|
+
.inputOptions(['-loop', '1'])
|
|
194
|
+
.input(audioPath)
|
|
195
|
+
.complexFilter([
|
|
196
|
+
`${kenBurnsFilter}[v]`,
|
|
197
|
+
])
|
|
198
|
+
.outputOptions([
|
|
199
|
+
'-map', '[v]',
|
|
200
|
+
'-map', '1:a',
|
|
201
|
+
'-c:v', 'libx264',
|
|
202
|
+
'-preset', 'veryfast',
|
|
203
|
+
'-crf', '20',
|
|
204
|
+
...audioOpts,
|
|
205
|
+
'-shortest',
|
|
206
|
+
`-t`, `${audioDuration}`,
|
|
207
|
+
'-pix_fmt', 'yuv420p',
|
|
208
|
+
'-movflags', '+faststart',
|
|
209
|
+
]);
|
|
210
|
+
} else {
|
|
211
|
+
// Simple static image loop
|
|
212
|
+
command
|
|
213
|
+
.input(backgroundPath)
|
|
214
|
+
.inputOptions(['-loop', '1'])
|
|
215
|
+
.input(audioPath)
|
|
216
|
+
.videoFilters([scaleCropFilter])
|
|
217
|
+
.outputOptions([
|
|
218
|
+
'-map', '0:v',
|
|
219
|
+
'-map', '1:a',
|
|
220
|
+
'-c:v', 'libx264',
|
|
221
|
+
'-preset', 'veryfast',
|
|
222
|
+
'-crf', '20',
|
|
223
|
+
...audioOpts,
|
|
224
|
+
'-shortest',
|
|
225
|
+
`-t`, `${audioDuration}`,
|
|
226
|
+
`-r`, `${fps}`,
|
|
227
|
+
'-pix_fmt', 'yuv420p',
|
|
228
|
+
'-movflags', '+faststart',
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// Video background
|
|
233
|
+
if (bgDuration > 0 && bgDuration < audioDuration) {
|
|
234
|
+
// Video is shorter than audio -> stream loop
|
|
235
|
+
command
|
|
236
|
+
.input(backgroundPath)
|
|
237
|
+
.inputOptions(['-stream_loop', '-1'])
|
|
238
|
+
.input(audioPath)
|
|
239
|
+
.videoFilters([scaleCropFilter])
|
|
240
|
+
.outputOptions([
|
|
241
|
+
'-map', '0:v',
|
|
242
|
+
'-map', '1:a',
|
|
243
|
+
'-c:v', 'libx264',
|
|
244
|
+
'-preset', 'veryfast',
|
|
245
|
+
'-crf', '20',
|
|
246
|
+
...audioOpts,
|
|
247
|
+
'-shortest',
|
|
248
|
+
`-t`, `${audioDuration}`,
|
|
249
|
+
`-r`, `${fps}`,
|
|
250
|
+
'-pix_fmt', 'yuv420p',
|
|
251
|
+
'-movflags', '+faststart',
|
|
252
|
+
]);
|
|
253
|
+
} else {
|
|
254
|
+
// Video is longer or equal -> trim to audioDuration
|
|
255
|
+
command
|
|
256
|
+
.input(backgroundPath)
|
|
257
|
+
.input(audioPath)
|
|
258
|
+
.videoFilters([scaleCropFilter])
|
|
259
|
+
.outputOptions([
|
|
260
|
+
'-map', '0:v',
|
|
261
|
+
'-map', '1:a',
|
|
262
|
+
'-c:v', 'libx264',
|
|
263
|
+
'-preset', 'veryfast',
|
|
264
|
+
'-crf', '20',
|
|
265
|
+
...audioOpts,
|
|
266
|
+
'-shortest',
|
|
267
|
+
`-t`, `${audioDuration}`,
|
|
268
|
+
`-r`, `${fps}`,
|
|
269
|
+
'-pix_fmt', 'yuv420p',
|
|
270
|
+
'-movflags', '+faststart',
|
|
271
|
+
]);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (onProgress) {
|
|
276
|
+
command.on('progress', onProgress);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
command
|
|
280
|
+
.output(outputPath)
|
|
281
|
+
.on('end', () => {
|
|
282
|
+
cleanup();
|
|
283
|
+
resolve({
|
|
284
|
+
outputPath,
|
|
285
|
+
duration: audioDuration,
|
|
286
|
+
});
|
|
287
|
+
})
|
|
288
|
+
.on('error', (err) => {
|
|
289
|
+
cleanup();
|
|
290
|
+
// Clean up partial output if any
|
|
291
|
+
if (fs.existsSync(outputPath)) {
|
|
292
|
+
try {
|
|
293
|
+
fs.unlinkSync(outputPath);
|
|
294
|
+
} catch {}
|
|
295
|
+
}
|
|
296
|
+
reject(new Error(`FFmpeg composition failed: ${err.message}`));
|
|
297
|
+
})
|
|
298
|
+
.run();
|
|
299
|
+
});
|
|
300
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import mime from 'mime-types';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { DEFAULT_MEDIA_CACHE_DIR } from './cache.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Infer extension from Content-Type header or download URL.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} [contentType]
|
|
12
|
+
* @param {string} [url]
|
|
13
|
+
* @param {'video' | 'photo'} [type='video']
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function inferExtension(contentType, url, type = 'video') {
|
|
17
|
+
if (contentType) {
|
|
18
|
+
const ext = mime.extension(contentType.split(';')[0].trim());
|
|
19
|
+
if (ext) {
|
|
20
|
+
if (ext === 'jpeg') return 'jpg';
|
|
21
|
+
return ext;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (url) {
|
|
26
|
+
try {
|
|
27
|
+
const parsedUrl = new URL(url);
|
|
28
|
+
const extname = path.extname(parsedUrl.pathname).toLowerCase().replace('.', '');
|
|
29
|
+
if (extname && /^[a-z0-9]+$/i.test(extname)) {
|
|
30
|
+
if (extname === 'jpeg') return 'jpg';
|
|
31
|
+
return extname;
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Ignore URL parse errors
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return type === 'video' ? 'mp4' : 'jpg';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Download a media asset and save it locally in cache.
|
|
43
|
+
*
|
|
44
|
+
* @param {import('../providers/index.js').UnifiedResult} asset
|
|
45
|
+
* @param {object} [options]
|
|
46
|
+
* @param {string} [options.cacheDir=DEFAULT_MEDIA_CACHE_DIR]
|
|
47
|
+
* @param {string} [options.targetPath]
|
|
48
|
+
* @param {boolean} [options.force=false]
|
|
49
|
+
* @param {number} [options.maxRetries=3]
|
|
50
|
+
* @param {typeof fetch} [options.fetchFn=fetch]
|
|
51
|
+
* @returns {Promise<{ localPath: string, size: number, ext: string, fromCache: boolean }>}
|
|
52
|
+
*/
|
|
53
|
+
export async function downloadAsset(asset, options = {}) {
|
|
54
|
+
const {
|
|
55
|
+
cacheDir = DEFAULT_MEDIA_CACHE_DIR,
|
|
56
|
+
targetPath,
|
|
57
|
+
force = false,
|
|
58
|
+
maxRetries = 3,
|
|
59
|
+
fetchFn = fetch,
|
|
60
|
+
} = options;
|
|
61
|
+
|
|
62
|
+
if (!asset || !asset.downloadUrl) {
|
|
63
|
+
throw new Error('Cannot download asset: missing downloadUrl.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const assetsDir = path.resolve(cacheDir, 'assets');
|
|
67
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
// If a specific targetPath is given, use it; otherwise standard naming: <provider>-<id>.<ext>
|
|
70
|
+
// Check if file already exists with any valid extension
|
|
71
|
+
if (!force && !targetPath) {
|
|
72
|
+
const candidateExts = asset.type === 'video' ? ['mp4', 'webm', 'mov'] : ['jpg', 'jpeg', 'png', 'webp'];
|
|
73
|
+
for (const ext of candidateExts) {
|
|
74
|
+
const existing = path.join(assetsDir, `${asset.provider}-${asset.id}.${ext}`);
|
|
75
|
+
if (fs.existsSync(existing)) {
|
|
76
|
+
const stat = fs.statSync(existing);
|
|
77
|
+
if (stat.size > 0) {
|
|
78
|
+
return { localPath: existing, size: stat.size, ext, fromCache: true };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let attempt = 0;
|
|
85
|
+
let lastError = null;
|
|
86
|
+
|
|
87
|
+
while (attempt < maxRetries) {
|
|
88
|
+
attempt++;
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetchFn(asset.downloadUrl, {
|
|
91
|
+
headers: {
|
|
92
|
+
'User-Agent': 'youtube-video-uploader-cli',
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
throw new Error(`Download failed with status ${res.status} (${res.statusText})`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const contentType = res.headers.get('content-type') || '';
|
|
101
|
+
const ext = inferExtension(contentType, asset.downloadUrl, asset.type);
|
|
102
|
+
const destination = targetPath
|
|
103
|
+
? path.resolve(targetPath)
|
|
104
|
+
: path.join(assetsDir, `${asset.provider}-${asset.id}.${ext}`);
|
|
105
|
+
|
|
106
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
107
|
+
const tempPath = `${destination}.${Date.now()}-${Math.random().toString(36).slice(2, 7)}.tmp`;
|
|
108
|
+
|
|
109
|
+
if (res.body && typeof res.body.getReader === 'function') {
|
|
110
|
+
// Node 18+ web stream
|
|
111
|
+
const nodeStream = Readable.fromWeb(res.body);
|
|
112
|
+
const fileStream = fs.createWriteStream(tempPath);
|
|
113
|
+
await pipeline(nodeStream, fileStream);
|
|
114
|
+
} else if (res.body && typeof res.body.pipe === 'function') {
|
|
115
|
+
// Node readable stream
|
|
116
|
+
const fileStream = fs.createWriteStream(tempPath);
|
|
117
|
+
await pipeline(res.body, fileStream);
|
|
118
|
+
} else {
|
|
119
|
+
// Fallback buffer write
|
|
120
|
+
const arrayBuffer = await res.arrayBuffer();
|
|
121
|
+
fs.writeFileSync(tempPath, Buffer.from(arrayBuffer));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const stat = fs.statSync(tempPath);
|
|
125
|
+
if (stat.size === 0) {
|
|
126
|
+
fs.unlinkSync(tempPath);
|
|
127
|
+
throw new Error('Downloaded file was 0 bytes.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Atomic rename
|
|
131
|
+
if (fs.existsSync(destination)) {
|
|
132
|
+
fs.unlinkSync(destination);
|
|
133
|
+
}
|
|
134
|
+
fs.renameSync(tempPath, destination);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
localPath: destination,
|
|
138
|
+
size: stat.size,
|
|
139
|
+
ext,
|
|
140
|
+
fromCache: false,
|
|
141
|
+
};
|
|
142
|
+
} catch (err) {
|
|
143
|
+
lastError = err;
|
|
144
|
+
if (attempt < maxRetries) {
|
|
145
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new Error(`Failed to download asset ${asset.provider}:${asset.id} after ${maxRetries} attempts: ${lastError?.message}`);
|
|
151
|
+
}
|