const { app, BrowserWindow, ipcMain, dialog } = require('electron'); const path = require('path'); const YTDlpWrap = require('yt-dlp-wrap').default; const fs = require('fs'); const os = require('os'); const { promisify } = require('util'); const rename = promisify(fs.rename); const copyFile = promisify(fs.copyFile); const unlink = promisify(fs.unlink); const rm = promisify(fs.rm); let mainWindow; let currentDownload = null; // Resolve binary paths const isDev = !app.isPackaged; const binDir = isDev ? path.join(__dirname, 'resources', 'bin') : path.join(process.resourcesPath, 'bin'); const ytDlpPath = path.join(binDir, 'yt-dlp'); const ffmpegPath = path.join(binDir, 'ffmpeg'); // Initialize yt-dlp with local binary const ytDlpWrap = new YTDlpWrap(ytDlpPath); // Set ffmpeg path for yt-dlp-wrap and child processes process.env.PATH = binDir + path.delimiter + process.env.PATH; async function createWindow() { mainWindow = new BrowserWindow({ width: 1000, height: 750, minWidth: 800, minHeight: 600, resizable: true, title: 'madapes Media Downloader', icon: path.join(binDir, '..', 'icon.png'), // Point to icon.png in resources titleBarStyle: 'hidden', trafficLightPosition: { x: 15, y: 15 }, backgroundColor: '#0f0f0f', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false } }); mainWindow.loadFile('index.html'); mainWindow.on('closed', () => { if (currentDownload) { try { currentDownload.kill(); } catch (e) { console.error('Failed to kill download on window close:', e); } currentDownload = null; } mainWindow = null; }); } app.whenReady().then(createWindow); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); // Helper to safely send messages to renderer function sendToRenderer(channel, data) { if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents) { mainWindow.webContents.send(channel, data); } } // Detect videos and formats ipcMain.handle('detect-videos', async (event, url) => { console.log(`[Main] Starting video detection for URL: ${url}`); try { // Get video info in JSON format. Using --no-playlist for MUCH faster initial detection. console.log('[Main] Calling ytDlpWrap.getVideoInfo with --no-playlist...'); const info = await ytDlpWrap.getVideoInfo([ url, '--no-playlist', '--extractor-args', 'generic:impersonate' ]); console.log('[Main] ytDlpWrap.getVideoInfo success'); console.log(`[Main] Info keys: ${Object.keys(info).join(', ')}`); // Handle both single videos and playlists/multi-parts const entries = Array.isArray(info.entries) ? info.entries.filter(Boolean) : [info]; console.log(`[Main] Found ${entries.length} entries`); // Detect if it's a playlist or part of one const isPlaylist = !!info.playlist_count || !!info.entries || url.includes('list=') || !!info.playlist_id || !!info._type === 'playlist'; const playlistCount = info.playlist_count || (Array.isArray(info.entries) ? info.entries.length : (url.includes('list=') ? '?' : 1)); console.log(`[Main] Detection Details - isPlaylist: ${isPlaylist}, playlistCount: ${playlistCount}, info._type: ${info._type}, has_entries: ${!!info.entries}`); const detected = entries.map((entry) => { const formats = (entry.formats || []) .filter((f) => f.url || f.format_id) // Some manifests might not have direct URLs yet .map((f) => ({ format_id: f.format_id, ext: f.ext || 'unknown', resolution: f.resolution || (f.width && f.height ? `${f.width}x${f.height}` : 'audio only'), width: f.width, height: f.height, vcodec: f.vcodec, acodec: f.acodec, fps: f.fps, tbr: f.tbr, // Total bitrate vbr: f.vbr, // Video bitrate abr: f.abr, // Audio bitrate filesize: f.filesize || f.filesize_approx || null, protocol: f.protocol, format_note: f.format_note, quality: f.quality, has_video: f.vcodec && f.vcodec !== 'none', has_audio: f.acodec && f.acodec !== 'none' })) // Sort: combined (video+audio) first, then by quality .sort((a, b) => { const typeScore = (x) => { if (x.has_video && x.has_audio) return 3; if (x.has_video) return 2; if (x.has_audio) return 1; return 0; }; const aScore = typeScore(a) * 1e9 + (a.height || 0) * 1e6 + (a.tbr || 0); const bScore = typeScore(b) * 1e9 + (b.height || 0) * 1e6 + (b.tbr || 0); return bScore - aScore; }); return { id: entry.id, title: entry.title || 'Unknown Title', webpage_url: entry.webpage_url || url, uploader: entry.uploader || entry.channel || 'Generic Site', duration: entry.duration || 0, thumbnail: entry.thumbnail || entry.thumbnails?.[0]?.url || '', description: entry.description || '', formats: formats }; }); console.log(`[Main] Detection complete. Returning ${detected.length} items. Playlist: ${isPlaylist} (${playlistCount})`); return { success: true, items: detected, isPlaylist: isPlaylist, playlistCount: playlistCount }; } catch (error) { console.error('Detection error:', error); return { success: false, message: error.message || 'Failed to detect videos' }; } }); // Sanitize filename for safe saving function sanitizeFilename(name) { return name .replace(/[<>:"/\\|?*]/g, '_') .replace(/\s+/g, ' ') .trim(); } // Handle download request ipcMain.handle('start-download', async (event, payload) => { const { url, formatId, extractAudio, audioFormat, title, downloadPlaylist } = payload; console.log(`[Main] Starting download for URL: ${url}, Format: ${formatId}, AudioOnly: ${extractAudio}, Title: ${title}, Playlist: ${downloadPlaylist}`); let tempDir = null; try { // Show save dialog const defaultExt = extractAudio ? (audioFormat || 'mp3') : 'mp4'; const sanitizedTitle = title ? sanitizeFilename(title).substring(0, 50) : 'download'; if (!mainWindow || mainWindow.isDestroyed()) { return { success: false, message: 'Window closed' }; } const result = await dialog.showSaveDialog(mainWindow, { title: extractAudio ? 'Save audio as' : 'Save video as', defaultPath: `${sanitizedTitle}.${defaultExt}`, filters: extractAudio ? [ { name: 'Audio Files', extensions: ['mp3', 'm4a', 'opus', 'wav'] }, { name: 'All Files', extensions: ['*'] } ] : [ { name: 'Video Files', extensions: ['mp4', 'mkv', 'webm', 'mov', 'avi', 'flv'] }, { name: 'Audio Files', extensions: ['mp3', 'm4a', 'opus', 'wav'] }, { name: 'All Files', extensions: ['*'] } ] }); if (result.canceled) { return { success: false, message: 'Cancelled' }; } const savePath = result.filePath; // Create a unique temp directory tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'madapes-downloader-')); const tempOutputFile = path.join(tempDir, `processed.${defaultExt}`); // Build download arguments const args = [ url, '--newline', '--extractor-args', 'generic:impersonate' ]; if (!downloadPlaylist) { args.push('--no-playlist'); } // Use a simple template for output to be robust across environments const outputTemplate = path.join(tempDir, 'media-%(autonumber)03d.%(ext)s'); args.push('-o', outputTemplate); console.log(`[Main] Executing yt-dlp with command: ${ytDlpPath} ${args.join(' ')}`); // Add format selection if (extractAudio) { args.push('--extract-audio'); args.push('--audio-format', audioFormat || 'mp3'); args.push('--postprocessor-args', `ffmpeg:-y`); args.push('-o', tempOutputFile); if (formatId && formatId !== 'best') { args.push('-f', formatId); } else { args.push('-f', 'bestaudio/best'); } } else if (formatId && formatId !== 'best') { args.push('-f', formatId); } else { // Default to best quality with video+audio args.push('-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best'); } let lastProgress = 0; let isConverting = false; let totalItems = 1; let currentItem = 1; // Wrap download in a Promise to correctly await process completion const downloadPromise = new Promise((resolve, reject) => { currentDownload = ytDlpWrap.exec(args); currentDownload.on('progress', (progress) => { try { let percent = 0; if (progress.percent) { const percentStr = String(progress.percent).replace('%', ''); percent = parseFloat(percentStr); } // If progress reaches 100 but the process is still running, it's likely converting if (percent >= 100 && extractAudio && !isConverting) { isConverting = true; } // Aggregate progress for multi-item downloads const aggregatePercent = ((currentItem - 1) * 100 + percent) / totalItems; if (Math.abs(aggregatePercent - lastProgress) > 0.1 || percent === 100) { lastProgress = aggregatePercent; let statusMsg = isConverting ? 'Converting to MP3...' : `Downloading... ${percent.toFixed(1)}%`; if (totalItems > 1) { statusMsg = `Downloading item ${currentItem} of ${totalItems}... (${percent.toFixed(1)}%)`; } sendToRenderer('download-progress', { percent: aggregatePercent, itemPercent: percent, currentItem: currentItem, totalItems: totalItems, speed: progress.currentSpeed || '', eta: progress.eta || '', totalSize: progress.totalSize || '', status: statusMsg }); } } catch (err) { console.error('Progress parsing error:', err); } }); currentDownload.on('ytDlpEvent', (eventType, eventData) => { console.log(`[Main] ytDlpEvent: ${eventType} | Data: ${eventData}`); // Check for multi-item info in logs if (eventType === 'download') { const itemMatch = eventData.match(/Downloading item (\d+) of (\d+)/); if (itemMatch) { currentItem = parseInt(itemMatch[1]); totalItems = parseInt(itemMatch[2]); } sendToRenderer('download-status', eventData); } }); currentDownload.on('close', (code) => { console.log(`[Main] yt-dlp process closed with code: ${code}`); if (code === 0) { resolve(); } else { reject(new Error(`yt-dlp exited with code ${code}`)); } }); currentDownload.on('error', (error) => { console.error('[Main] yt-dlp process error:', error); sendToRenderer('download-error', error.message); reject(error); }); }); await downloadPromise; console.log('[Main] Download promise resolved.'); // Verify and move files from temp to final destination const files = fs.readdirSync(tempDir).sort(); console.log(`[Main] Files in tempDir: ${JSON.stringify(files)}`); if (files.length === 0) { console.error(`[Main] Error: No files found in ${tempDir}`); throw new Error('No files were downloaded'); } sendToRenderer('download-progress', { percent: 100, status: 'Finishing up...' }); if (files.length === 1) { // Single file: move to the exact savePath the user chose const actualFile = path.join(tempDir, files[0]); try { await rename(actualFile, savePath); } catch (moveErr) { await copyFile(actualFile, savePath); await unlink(actualFile); } } else { // Multiple files: creates a folder if savePath was a file, or puts them in the Dir // Actually, dialog.showSaveDialog returns a FILE path. // We'll create a folder at savePath (minus extension) and put all files there. const targetFolder = savePath.replace(/\.[^/.]+$/, ""); if (!fs.existsSync(targetFolder)) { fs.mkdirSync(targetFolder, { recursive: true }); } for (const file of files) { const src = path.join(tempDir, file); const dest = path.join(targetFolder, file); try { await rename(src, dest); } catch (moveErr) { await copyFile(src, dest); await unlink(src); } } } sendToRenderer('download-complete', { success: true, path: files.length === 1 ? savePath : savePath.replace(/\.[^/.]+$/, "") }); return { success: true, path: files.length === 1 ? savePath : savePath.replace(/\.[^/.]+$/, "") }; } catch (error) { console.error('Download error:', error); sendToRenderer('download-error', error.message); return { success: false, message: error.message }; } finally { currentDownload = null; if (tempDir && fs.existsSync(tempDir)) { try { await rm(tempDir, { recursive: true, force: true }); } catch (err) { console.error('Temp cleanup error:', err); } } } }); // Handle cancel request ipcMain.handle('cancel-download', async () => { if (currentDownload) { try { currentDownload.kill(); currentDownload = null; return { success: true }; } catch (err) { return { success: false, message: err.message }; } } return { success: false, message: 'No active download' }; });