@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,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,227 @@
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
+
7
+ // Configure ffmpeg and ffprobe paths if available
8
+ if (ffmpegStatic && fs.existsSync(ffmpegStatic)) {
9
+ ffmpeg.setFfmpegPath(ffmpegStatic);
10
+ }
11
+ if (ffprobeStatic?.path && fs.existsSync(ffprobeStatic.path)) {
12
+ ffmpeg.setFfprobePath(ffprobeStatic.path);
13
+ }
14
+
15
+ /**
16
+ * Get the duration of an audio or video file in seconds using ffprobe.
17
+ *
18
+ * @param {string} filePath
19
+ * @returns {Promise<number>} Duration in seconds
20
+ */
21
+ export function getMediaDuration(filePath) {
22
+ return new Promise((resolve, reject) => {
23
+ if (!fs.existsSync(filePath)) {
24
+ return reject(new Error(`File not found: ${filePath}`));
25
+ }
26
+ ffmpeg.ffprobe(filePath, (err, metadata) => {
27
+ if (err) {
28
+ return reject(new Error(`FFprobe error for ${filePath}: ${err.message}`));
29
+ }
30
+ const duration = metadata.format?.duration;
31
+ if (typeof duration === 'number') {
32
+ return resolve(duration);
33
+ }
34
+ if (typeof duration === 'string') {
35
+ const parsed = parseFloat(duration);
36
+ if (!isNaN(parsed)) {
37
+ return resolve(parsed);
38
+ }
39
+ }
40
+ // Check streams if format duration is missing
41
+ const streamWithDuration = metadata.streams?.find(s => s.duration);
42
+ if (streamWithDuration?.duration) {
43
+ return resolve(parseFloat(streamWithDuration.duration));
44
+ }
45
+ reject(new Error(`Could not determine duration for: ${filePath}`));
46
+ });
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Compose a background video/photo and audio track into an MP4 file using ffmpeg.
52
+ *
53
+ * @param {object} options
54
+ * @param {string} options.backgroundPath - Local path to background video or image
55
+ * @param {string} options.audioPath - Local path to audio track
56
+ * @param {string} options.outputPath - Destination path for final MP4
57
+ * @param {'video-loop' | 'video-trim' | 'ken-burns'} [options.mode='video-loop'] - Composition mode
58
+ * @param {number} [options.targetWidth=1920]
59
+ * @param {number} [options.targetHeight=1080]
60
+ * @param {number} [options.fps=30]
61
+ * @param {boolean} [options.dryRun=false]
62
+ * @param {(progress: { percent?: number, timemark?: string }) => void} [options.onProgress]
63
+ * @returns {Promise<{ outputPath: string, duration: number, dryRun?: boolean, command?: string }>}
64
+ */
65
+ export async function composeVideo(options) {
66
+ const {
67
+ backgroundPath,
68
+ audioPath,
69
+ outputPath,
70
+ mode = 'video-loop',
71
+ targetWidth = 1920,
72
+ targetHeight = 1080,
73
+ fps = 30,
74
+ dryRun = false,
75
+ onProgress,
76
+ } = options;
77
+
78
+ if (!fs.existsSync(backgroundPath)) {
79
+ throw new Error(`Background media file not found: ${backgroundPath}`);
80
+ }
81
+ if (!fs.existsSync(audioPath)) {
82
+ throw new Error(`Audio file not found: ${audioPath}`);
83
+ }
84
+ if (!outputPath) {
85
+ throw new Error('Output path is required for video composition.');
86
+ }
87
+
88
+ // Ensure output directory exists
89
+ fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
90
+
91
+ const audioDuration = await getMediaDuration(audioPath);
92
+ let bgDuration = 0;
93
+ try {
94
+ bgDuration = await getMediaDuration(backgroundPath);
95
+ } catch {
96
+ // Might be static photo
97
+ bgDuration = 0;
98
+ }
99
+
100
+ const isImage = bgDuration === 0 || mode === 'ken-burns';
101
+ const scaleCropFilter = `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=increase,crop=${targetWidth}:${targetHeight},format=yuv420p`;
102
+
103
+ if (dryRun) {
104
+ const planDescription = isImage
105
+ ? `Image (${backgroundPath}) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s) -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`
106
+ : `Video (${backgroundPath}, ${bgDuration.toFixed(1)}s) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s) -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`;
107
+
108
+ return {
109
+ outputPath,
110
+ duration: audioDuration,
111
+ dryRun: true,
112
+ command: planDescription,
113
+ };
114
+ }
115
+
116
+ return new Promise((resolve, reject) => {
117
+ const command = ffmpeg();
118
+
119
+ if (isImage) {
120
+ if (mode === 'ken-burns') {
121
+ const totalFrames = Math.max(30, Math.ceil(audioDuration * fps));
122
+ command
123
+ .input(backgroundPath)
124
+ .inputOptions(['-loop', '1'])
125
+ .input(audioPath)
126
+ .complexFilter([
127
+ `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}[v]`,
128
+ ])
129
+ .outputOptions([
130
+ '-map', '[v]',
131
+ '-map', '1:a',
132
+ '-c:v', 'libx264',
133
+ '-preset', 'veryfast',
134
+ '-crf', '20',
135
+ '-c:a', 'aac',
136
+ '-b:a', '192k',
137
+ '-shortest',
138
+ `-t`, `${audioDuration}`,
139
+ '-pix_fmt', 'yuv420p',
140
+ '-movflags', '+faststart',
141
+ ]);
142
+ } else {
143
+ // Simple static image loop
144
+ command
145
+ .input(backgroundPath)
146
+ .inputOptions(['-loop', '1'])
147
+ .input(audioPath)
148
+ .videoFilters([scaleCropFilter])
149
+ .outputOptions([
150
+ '-c:v', 'libx264',
151
+ '-preset', 'veryfast',
152
+ '-crf', '20',
153
+ '-c:a', 'aac',
154
+ '-b:a', '192k',
155
+ '-shortest',
156
+ `-t`, `${audioDuration}`,
157
+ `-r`, `${fps}`,
158
+ '-pix_fmt', 'yuv420p',
159
+ '-movflags', '+faststart',
160
+ ]);
161
+ }
162
+ } else {
163
+ // Video background
164
+ if (bgDuration > 0 && bgDuration < audioDuration) {
165
+ // Video is shorter than audio -> stream loop
166
+ command
167
+ .input(backgroundPath)
168
+ .inputOptions(['-stream_loop', '-1'])
169
+ .input(audioPath)
170
+ .videoFilters([scaleCropFilter])
171
+ .outputOptions([
172
+ '-c:v', 'libx264',
173
+ '-preset', 'veryfast',
174
+ '-crf', '20',
175
+ '-c:a', 'aac',
176
+ '-b:a', '192k',
177
+ '-shortest',
178
+ `-t`, `${audioDuration}`,
179
+ `-r`, `${fps}`,
180
+ '-pix_fmt', 'yuv420p',
181
+ '-movflags', '+faststart',
182
+ ]);
183
+ } else {
184
+ // Video is longer or equal -> trim to audioDuration
185
+ command
186
+ .input(backgroundPath)
187
+ .input(audioPath)
188
+ .videoFilters([scaleCropFilter])
189
+ .outputOptions([
190
+ '-c:v', 'libx264',
191
+ '-preset', 'veryfast',
192
+ '-crf', '20',
193
+ '-c:a', 'aac',
194
+ '-b:a', '192k',
195
+ '-shortest',
196
+ `-t`, `${audioDuration}`,
197
+ `-r`, `${fps}`,
198
+ '-pix_fmt', 'yuv420p',
199
+ '-movflags', '+faststart',
200
+ ]);
201
+ }
202
+ }
203
+
204
+ if (onProgress) {
205
+ command.on('progress', onProgress);
206
+ }
207
+
208
+ command
209
+ .output(outputPath)
210
+ .on('end', () => {
211
+ resolve({
212
+ outputPath,
213
+ duration: audioDuration,
214
+ });
215
+ })
216
+ .on('error', (err) => {
217
+ // Clean up partial output if any
218
+ if (fs.existsSync(outputPath)) {
219
+ try {
220
+ fs.unlinkSync(outputPath);
221
+ } catch {}
222
+ }
223
+ reject(new Error(`FFmpeg composition failed: ${err.message}`));
224
+ })
225
+ .run();
226
+ });
227
+ }
@@ -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
+ }