/ src / download / media-download.ts
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  }