media-download.ts
1 /** 2 * Media download helper — shared logic for batch downloading images/videos. 3 * 4 * Used by: xiaohongshu/download, twitter/download, bilibili/download, 5 * and future media adapters. 6 * 7 * Flow: MediaItem[] → DownloadProgressTracker → httpDownload/ytdlpDownload → results 8 */ 9 10 import * as fs from 'node:fs'; 11 import * as path from 'node:path'; 12 import { getErrorMessage } from '../errors.js'; 13 import { 14 httpDownload, 15 ytdlpDownload, 16 checkYtdlp, 17 getTempDir, 18 exportCookiesToNetscape, 19 } from './index.js'; 20 import type { BrowserCookie } from '../types.js'; 21 import { DownloadProgressTracker, formatBytes } from './progress.js'; 22 23 // ============================================================ 24 // Types 25 // ============================================================ 26 27 export interface MediaItem { 28 type: 'image' | 'video' | 'video-tweet' | 'video-ytdlp'; 29 url: string; 30 /** Optional custom filename (without directory) */ 31 filename?: string; 32 } 33 34 export interface MediaDownloadOptions { 35 output: string; 36 /** Subdirectory inside output */ 37 subdir?: string; 38 /** Cookie string for HTTP downloads */ 39 cookies?: string; 40 /** Raw browser cookies — auto-exported to Netscape for yt-dlp, auto-cleaned up */ 41 browserCookies?: BrowserCookie[]; 42 /** Timeout in ms (default: 30000 for images, 60000 for videos) */ 43 timeout?: number; 44 /** File name prefix (default: 'download') */ 45 filenamePrefix?: string; 46 /** Extra yt-dlp args */ 47 ytdlpExtraArgs?: string[]; 48 /** Whether to show progress (default: true) */ 49 verbose?: boolean; 50 } 51 52 export interface MediaDownloadResult { 53 index: number; 54 type: string; 55 status: string; 56 size: string; 57 } 58 59 // ============================================================ 60 // Main API 61 // ============================================================ 62 63 /** 64 * Batch download media files with progress tracking. 65 * 66 * Handles: 67 * - DownloadProgressTracker for terminal UX 68 * - Automatic httpDownload vs ytdlpDownload routing via MediaItem.type 69 * - Cookie export to Netscape format for yt-dlp (auto-cleanup) 70 * - Directory creation 71 * - Error handling with per-file results 72 */ 73 export async function downloadMedia( 74 items: MediaItem[], 75 options: MediaDownloadOptions, 76 ): Promise<MediaDownloadResult[]> { 77 const { 78 output, 79 subdir, 80 cookies, 81 browserCookies, 82 timeout, 83 filenamePrefix = 'download', 84 ytdlpExtraArgs = [], 85 verbose = true, 86 } = options; 87 88 if (!items || items.length === 0) { 89 return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }]; 90 } 91 92 // Create output directory 93 const outputDir = subdir ? path.join(output, subdir) : output; 94 fs.mkdirSync(outputDir, { recursive: true }); 95 96 // Pre-check yt-dlp availability (once, not per-item) 97 const hasYtdlp = checkYtdlp(); 98 99 // Auto-export browser cookies to Netscape format for yt-dlp 100 let cookiesFile: string | undefined; 101 const needsYtdlp = items.some(m => m.type === 'video-tweet' || m.type === 'video-ytdlp'); 102 if (needsYtdlp && browserCookies && browserCookies.length > 0) { 103 const tempDir = getTempDir(); 104 fs.mkdirSync(tempDir, { recursive: true }); 105 cookiesFile = path.join(tempDir, `media_cookies_${Date.now()}.txt`); 106 exportCookiesToNetscape(browserCookies, cookiesFile); 107 } 108 109 const tracker = new DownloadProgressTracker(items.length, verbose); 110 const results: MediaDownloadResult[] = []; 111 112 try { 113 for (let i = 0; i < items.length; i++) { 114 const media = items[i]; 115 const isVideo = media.type !== 'image'; 116 const ext = isVideo ? 'mp4' : 'jpg'; 117 const filename = media.filename || `${filenamePrefix}_${i + 1}.${ext}`; 118 const destPath = path.join(outputDir, filename); 119 120 const progressBar = tracker.onFileStart(filename, i); 121 122 try { 123 let result: { success: boolean; size: number; error?: string }; 124 const useYtdlp = (media.type === 'video-tweet' || media.type === 'video-ytdlp') && hasYtdlp; 125 126 if (useYtdlp) { 127 result = await ytdlpDownload(media.url, destPath, { 128 cookiesFile, 129 extraArgs: ytdlpExtraArgs, 130 onProgress: (percent) => { 131 if (progressBar) progressBar.update(percent, 100); 132 }, 133 }); 134 } else { 135 // Direct HTTP download for images and direct video URLs 136 const dlTimeout = timeout || (isVideo ? 60000 : 30000); 137 result = await httpDownload(media.url, destPath, { 138 cookies, 139 timeout: dlTimeout, 140 onProgress: (received, total) => { 141 if (progressBar) progressBar.update(received, total); 142 }, 143 }); 144 } 145 146 if (progressBar) { 147 progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined); 148 } 149 tracker.onFileComplete(result.success); 150 151 results.push({ 152 index: i + 1, 153 type: media.type === 'video-tweet' || media.type === 'video-ytdlp' ? 'video' : media.type, 154 status: result.success ? 'success' : 'failed', 155 size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'), 156 }); 157 } catch (err) { 158 const msg = getErrorMessage(err); 159 if (progressBar) progressBar.fail(msg); 160 tracker.onFileComplete(false); 161 162 results.push({ 163 index: i + 1, 164 type: media.type, 165 status: 'failed', 166 size: msg, 167 }); 168 } 169 } 170 } finally { 171 tracker.finish(); 172 173 // Auto-cleanup exported cookies file 174 if (cookiesFile && fs.existsSync(cookiesFile)) { 175 fs.unlinkSync(cookiesFile); 176 } 177 } 178 179 return results; 180 }