| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406 |
- const { app, BrowserWindow, ipcMain, dialog, shell, Notification } = require('electron')
- const path = require('path')
- const fs = require('fs')
- const { spawn } = require('child_process')
- const notifier = require('node-notifier')
- const ffmpegConverter = require('../scripts/utils/ffmpeg-converter')
- const DownloadManager = require('./download-manager')
- // Keep a global reference of the window object
- let mainWindow
- // Initialize download manager
- const downloadManager = new DownloadManager()
- function createWindow() {
- // Create the browser window
- mainWindow = new BrowserWindow({
- width: 1200,
- height: 800,
- minWidth: 800,
- minHeight: 600,
- titleBarStyle: 'hiddenInset', // macOS style - hides title bar but keeps native traffic lights
- webPreferences: {
- nodeIntegration: false,
- contextIsolation: true,
- enableRemoteModule: false,
- preload: path.join(__dirname, 'preload.js')
- },
- icon: path.join(__dirname, '../assets/icons/logo.png'), // App icon
- show: false // Don't show until ready
- })
- // Load the app
- mainWindow.loadFile(path.join(__dirname, '../index.html'))
- // Show window when ready to prevent visual flash
- mainWindow.once('ready-to-show', () => {
- mainWindow.show()
- })
- // Open DevTools in development
- if (process.argv.includes('--dev')) {
- mainWindow.webContents.openDevTools()
- }
- // Handle window closed
- mainWindow.on('closed', () => {
- mainWindow = null
- })
- // Handle external links
- mainWindow.webContents.setWindowOpenHandler(({ url }) => {
- shell.openExternal(url)
- return { action: 'deny' }
- })
- }
- // App event handlers
- app.whenReady().then(createWindow)
- app.on('window-all-closed', () => {
- if (process.platform !== 'darwin') {
- app.quit()
- }
- })
- app.on('activate', () => {
- if (BrowserWindow.getAllWindows().length === 0) {
- createWindow()
- }
- })
- // IPC handlers for file system operations
- ipcMain.handle('select-save-directory', async () => {
- try {
- const result = await dialog.showOpenDialog(mainWindow, {
- properties: ['openDirectory', 'createDirectory'],
- title: 'Select Download Directory',
- buttonLabel: 'Select Folder',
- message: 'Choose where to save downloaded videos'
- })
-
- if (!result.canceled && result.filePaths.length > 0) {
- const selectedPath = result.filePaths[0]
-
- // Verify directory is writable
- try {
- await fs.promises.access(selectedPath, fs.constants.W_OK)
- console.log('Selected save directory:', selectedPath)
- return { success: true, path: selectedPath }
- } catch (error) {
- console.error('Directory not writable:', error)
- return {
- success: false,
- error: 'Selected directory is not writable. Please choose a different location.'
- }
- }
- }
-
- return { success: false, error: 'No directory selected' }
- } catch (error) {
- console.error('Error selecting save directory:', error)
- return {
- success: false,
- error: `Failed to open directory selector: ${error.message}`
- }
- }
- })
- // Create directory with recursive option
- ipcMain.handle('create-directory', async (event, dirPath) => {
- try {
- if (!dirPath || typeof dirPath !== 'string') {
- return { success: false, error: 'Invalid directory path' }
- }
- // Expand ~ to home directory
- const expandedPath = dirPath.startsWith('~')
- ? path.join(require('os').homedir(), dirPath.slice(1))
- : dirPath
- // Create directory recursively
- await fs.promises.mkdir(expandedPath, { recursive: true })
- console.log('Directory created successfully:', expandedPath)
- return { success: true, path: expandedPath }
- } catch (error) {
- console.error('Error creating directory:', error)
- return {
- success: false,
- error: `Failed to create directory: ${error.message}`
- }
- }
- })
- ipcMain.handle('select-cookie-file', async () => {
- try {
- const result = await dialog.showOpenDialog(mainWindow, {
- properties: ['openFile'],
- filters: [
- { name: 'Cookie Files', extensions: ['txt'] },
- { name: 'Netscape Cookie Files', extensions: ['cookies'] },
- { name: 'All Files', extensions: ['*'] }
- ],
- title: 'Select Cookie File',
- buttonLabel: 'Select Cookie File',
- message: 'Choose a cookie file for age-restricted content'
- })
-
- if (!result.canceled && result.filePaths.length > 0) {
- const selectedPath = result.filePaths[0]
-
- // Verify file exists and is readable
- try {
- await fs.promises.access(selectedPath, fs.constants.R_OK)
- const stats = await fs.promises.stat(selectedPath)
-
- if (stats.size === 0) {
- return {
- success: false,
- error: 'Selected cookie file is empty. Please choose a valid cookie file.'
- }
- }
-
- console.log('Selected cookie file:', selectedPath)
- return { success: true, path: selectedPath }
- } catch (error) {
- console.error('Cookie file not accessible:', error)
- return {
- success: false,
- error: 'Selected cookie file is not readable. Please check file permissions.'
- }
- }
- }
-
- return { success: false, error: 'No cookie file selected' }
- } catch (error) {
- console.error('Error selecting cookie file:', error)
- return {
- success: false,
- error: `Failed to open file selector: ${error.message}`
- }
- }
- })
- // Desktop notification system
- ipcMain.handle('show-notification', async (event, options) => {
- try {
- const notificationOptions = {
- title: options.title || 'GrabZilla',
- message: options.message || '',
- icon: options.icon || path.join(__dirname, '../assets/icons/logo.png'),
- sound: options.sound !== false, // Default to true
- wait: options.wait || false,
- timeout: options.timeout || 5
- }
- // Use native Electron notifications if supported, fallback to node-notifier
- if (Notification.isSupported()) {
- const notification = new Notification({
- title: notificationOptions.title,
- body: notificationOptions.message,
- icon: notificationOptions.icon,
- silent: !notificationOptions.sound
- })
- notification.show()
-
- if (options.onClick && typeof options.onClick === 'function') {
- notification.on('click', options.onClick)
- }
- return { success: true, method: 'electron' }
- } else {
- // Fallback to node-notifier for older systems
- return new Promise((resolve) => {
- notifier.notify(notificationOptions, (err, response) => {
- if (err) {
- console.error('Notification error:', err)
- resolve({ success: false, error: err.message })
- } else {
- resolve({ success: true, method: 'node-notifier', response })
- }
- })
- })
- }
- } catch (error) {
- console.error('Failed to show notification:', error)
- return { success: false, error: error.message }
- }
- })
- // Error dialog system
- ipcMain.handle('show-error-dialog', async (event, options) => {
- try {
- const dialogOptions = {
- type: 'error',
- title: options.title || 'Error',
- message: options.message || 'An error occurred',
- detail: options.detail || '',
- buttons: options.buttons || ['OK'],
- defaultId: options.defaultId || 0,
- cancelId: options.cancelId || 0
- }
- const result = await dialog.showMessageBox(mainWindow, dialogOptions)
- return { success: true, response: result.response, checkboxChecked: result.checkboxChecked }
- } catch (error) {
- console.error('Failed to show error dialog:', error)
- return { success: false, error: error.message }
- }
- })
- // Info dialog system
- ipcMain.handle('show-info-dialog', async (event, options) => {
- try {
- const dialogOptions = {
- type: 'info',
- title: options.title || 'Information',
- message: options.message || '',
- detail: options.detail || '',
- buttons: options.buttons || ['OK'],
- defaultId: options.defaultId || 0
- }
- const result = await dialog.showMessageBox(mainWindow, dialogOptions)
- return { success: true, response: result.response }
- } catch (error) {
- console.error('Failed to show info dialog:', error)
- return { success: false, error: error.message }
- }
- })
- // Binary dependency management
- ipcMain.handle('check-binary-dependencies', async () => {
- const binariesPath = path.join(__dirname, '../binaries')
- const results = {
- binariesPath,
- ytDlp: { available: false, path: null, error: null },
- ffmpeg: { available: false, path: null, error: null },
- allAvailable: false
- }
- try {
- // Ensure binaries directory exists
- if (!fs.existsSync(binariesPath)) {
- const error = `Binaries directory not found: ${binariesPath}`
- console.error(error)
- results.ytDlp.error = error
- results.ffmpeg.error = error
- return results
- }
- // Check yt-dlp
- const ytDlpPath = getBinaryPath('yt-dlp')
- results.ytDlp.path = ytDlpPath
-
- if (fs.existsSync(ytDlpPath)) {
- try {
- // Test if binary is executable
- await fs.promises.access(ytDlpPath, fs.constants.X_OK)
- results.ytDlp.available = true
- console.log('yt-dlp binary found and executable:', ytDlpPath)
- } catch (error) {
- results.ytDlp.error = 'yt-dlp binary exists but is not executable'
- console.error(results.ytDlp.error, error)
- }
- } else {
- results.ytDlp.error = 'yt-dlp binary not found'
- console.error(results.ytDlp.error, ytDlpPath)
- }
- // Check ffmpeg
- const ffmpegPath = getBinaryPath('ffmpeg')
- results.ffmpeg.path = ffmpegPath
-
- if (fs.existsSync(ffmpegPath)) {
- try {
- // Test if binary is executable
- await fs.promises.access(ffmpegPath, fs.constants.X_OK)
- results.ffmpeg.available = true
- console.log('ffmpeg binary found and executable:', ffmpegPath)
- } catch (error) {
- results.ffmpeg.error = 'ffmpeg binary exists but is not executable'
- console.error(results.ffmpeg.error, error)
- }
- } else {
- results.ffmpeg.error = 'ffmpeg binary not found'
- console.error(results.ffmpeg.error, ffmpegPath)
- }
- results.allAvailable = results.ytDlp.available && results.ffmpeg.available
- return results
- } catch (error) {
- console.error('Error checking binary dependencies:', error)
- results.ytDlp.error = error.message
- results.ffmpeg.error = error.message
- return results
- }
- })
- // Version checking cache (1-hour duration)
- let versionCache = {
- ytdlp: { latestVersion: null, timestamp: 0 },
- ffmpeg: { latestVersion: null, timestamp: 0 }
- }
- const CACHE_DURATION = 1000 * 60 * 60 // 1 hour
- /**
- * Compare two version strings
- * @param {string} v1 - First version (e.g., "2024.01.15")
- * @param {string} v2 - Second version
- * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal
- */
- function compareVersions(v1, v2) {
- if (!v1 || !v2) return 0
- // Remove non-numeric characters and split
- const parts1 = v1.replace(/[^0-9.]/g, '').split('.').map(Number)
- const parts2 = v2.replace(/[^0-9.]/g, '').split('.').map(Number)
- for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
- const p1 = parts1[i] || 0
- const p2 = parts2[i] || 0
- if (p1 > p2) return 1
- if (p1 < p2) return -1
- }
- return 0
- }
- /**
- * Get cached version or fetch from API
- * @param {string} key - Cache key ('ytdlp' or 'ffmpeg')
- * @param {Function} fetchFn - Function to fetch latest version
- * @returns {Promise<string|null>} Latest version or null
- */
- async function getCachedVersion(key, fetchFn) {
- const cached = versionCache[key]
- const now = Date.now()
- // Return cached if still valid
- if (cached.latestVersion && (now - cached.timestamp) < CACHE_DURATION) {
- return cached.latestVersion
- }
- // Fetch new version
- try {
- const version = await fetchFn()
- versionCache[key] = { latestVersion: version, timestamp: now }
- return version
- } catch (error) {
- console.warn(`Failed to fetch latest ${key} version:`, error.message)
- // Return cached even if expired on error
- return cached.latestVersion
- }
- }
- /**
- * Check latest yt-dlp version from GitHub API
- * @returns {Promise<string>} Latest version tag
- */
- async function checkLatestYtDlpVersion() {
- const https = require('https')
- return new Promise((resolve, reject) => {
- const options = {
- hostname: 'api.github.com',
- path: '/repos/yt-dlp/yt-dlp/releases/latest',
- method: 'GET',
- headers: {
- 'User-Agent': 'GrabZilla-App',
- 'Accept': 'application/vnd.github.v3+json'
- },
- timeout: 5000
- }
- const req = https.request(options, (res) => {
- let data = ''
- res.on('data', (chunk) => {
- data += chunk
- })
- res.on('end', () => {
- try {
- if (res.statusCode === 200) {
- const json = JSON.parse(data)
- // tag_name format: "2024.01.15"
- resolve(json.tag_name || null)
- } else if (res.statusCode === 403) {
- // Rate limited
- reject(new Error('GitHub API rate limit exceeded'))
- } else {
- reject(new Error(`GitHub API returned ${res.statusCode}`))
- }
- } catch (error) {
- reject(error)
- }
- })
- })
- req.on('error', reject)
- req.on('timeout', () => {
- req.destroy()
- reject(new Error('GitHub API request timeout'))
- })
- req.end()
- })
- }
- // Binary management
- ipcMain.handle('check-binary-versions', async () => {
- const binariesPath = path.join(__dirname, '../binaries')
- const results = {}
- let hasMissingBinaries = false
- try {
- // Ensure binaries directory exists
- if (!fs.existsSync(binariesPath)) {
- console.warn('Binaries directory not found:', binariesPath)
- hasMissingBinaries = true
- return { ytDlp: { available: false }, ffmpeg: { available: false } }
- }
-
- // Check yt-dlp version
- const ytDlpPath = getBinaryPath('yt-dlp')
- if (fs.existsSync(ytDlpPath)) {
- const ytDlpVersion = await runCommand(ytDlpPath, ['--version'])
- results.ytDlp = {
- version: ytDlpVersion.trim(),
- available: true,
- updateAvailable: false,
- latestVersion: null
- }
- // Check for updates (non-blocking)
- try {
- const latestVersion = await getCachedVersion('ytdlp', checkLatestYtDlpVersion)
- if (latestVersion) {
- results.ytDlp.latestVersion = latestVersion
- results.ytDlp.updateAvailable = compareVersions(latestVersion, results.ytDlp.version) > 0
- }
- } catch (updateError) {
- console.warn('Could not check for yt-dlp updates:', updateError.message)
- // Continue without update info
- }
- } else {
- results.ytDlp = { available: false }
- hasMissingBinaries = true
- }
- // Check ffmpeg version
- const ffmpegPath = getBinaryPath('ffmpeg')
- if (fs.existsSync(ffmpegPath)) {
- const ffmpegVersion = await runCommand(ffmpegPath, ['-version'])
- const versionMatch = ffmpegVersion.match(/ffmpeg version ([^\s]+)/)
- results.ffmpeg = {
- version: versionMatch ? versionMatch[1] : 'unknown',
- available: true,
- updateAvailable: false,
- latestVersion: null
- }
- // Note: ffmpeg doesn't have easy API for latest version
- // Skip update checking for ffmpeg for now
- } else {
- results.ffmpeg = { available: false }
- hasMissingBinaries = true
- }
- // Show native notification if binaries are missing
- if (hasMissingBinaries && mainWindow) {
- const missingList = [];
- if (!results.ytDlp || !results.ytDlp.available) missingList.push('yt-dlp');
- if (!results.ffmpeg || !results.ffmpeg.available) missingList.push('ffmpeg');
- console.error(`❌ Missing binaries detected: ${missingList.join(', ')}`);
- // Send notification via IPC to show dialog
- mainWindow.webContents.send('binaries-missing', {
- missing: missingList,
- message: `Required binaries missing: ${missingList.join(', ')}`
- });
- }
- } catch (error) {
- console.error('Error checking binary versions:', error)
- // Return safe defaults on error
- results.ytDlp = results.ytDlp || { available: false }
- results.ffmpeg = results.ffmpeg || { available: false }
- }
- return results
- })
- // Video download handler with format conversion integration (uses DownloadManager for parallel processing)
- ipcMain.handle('download-video', async (event, { videoId, url, quality, format, savePath, cookieFile }) => {
- const ytDlpPath = getBinaryPath('yt-dlp')
- const ffmpegPath = getBinaryPath('ffmpeg')
- // Validate binaries exist before attempting download
- if (!fs.existsSync(ytDlpPath)) {
- const error = 'yt-dlp binary not found. Please run "npm run setup" to download required binaries.'
- console.error('❌', error)
- throw new Error(error)
- }
- // Check ffmpeg if format conversion is required
- const requiresConversion = format && format !== 'None'
- if (requiresConversion && !fs.existsSync(ffmpegPath)) {
- const error = 'ffmpeg binary not found. Required for format conversion. Please run "npm run setup".'
- console.error('❌', error)
- throw new Error(error)
- }
- // Validate inputs
- if (!videoId || !url || !quality || !savePath) {
- throw new Error('Missing required parameters: videoId, url, quality, or savePath')
- }
- // Check if format conversion is required (we already validated ffmpeg exists above if needed)
- const requiresConversionCheck = format && format !== 'None' && ffmpegConverter.isAvailable()
- console.log('Adding download to queue:', {
- videoId, url, quality, format, savePath, requiresConversion: requiresConversionCheck
- })
- // Define download function
- const downloadFn = async ({ url, quality, format, savePath, cookieFile }) => {
- try {
- // Step 1: Download video with yt-dlp
- const downloadResult = await downloadWithYtDlp(event, {
- url, quality, savePath, cookieFile, requiresConversion: requiresConversionCheck
- })
- // Step 2: Convert format if required
- if (requiresConversionCheck && downloadResult.success) {
- const conversionResult = await convertVideoFormat(event, {
- url,
- inputPath: downloadResult.filePath,
- format,
- quality,
- savePath
- })
- return {
- success: true,
- filename: conversionResult.filename,
- originalFile: downloadResult.filename,
- convertedFile: conversionResult.filename,
- message: 'Download and conversion completed successfully'
- }
- }
- return downloadResult
- } catch (error) {
- console.error('Download/conversion process failed:', error)
- throw error
- }
- }
- // Add to download manager queue
- return await downloadManager.addDownload({
- videoId,
- url,
- quality,
- format,
- savePath,
- cookieFile,
- downloadFn
- })
- })
- /**
- * Download video using yt-dlp
- */
- async function downloadWithYtDlp(event, { url, quality, savePath, cookieFile, requiresConversion }) {
- const ytDlpPath = getBinaryPath('yt-dlp')
-
- // Build yt-dlp arguments
- const args = [
- '--newline', // Force progress on new lines for better parsing
- '--no-warnings', // Reduce noise in output
- '-f', getQualityFormat(quality),
- '-o', path.join(savePath, '%(title)s.%(ext)s'),
- url
- ]
-
- // Add cookie file if provided
- if (cookieFile && fs.existsSync(cookieFile)) {
- args.unshift('--cookies', cookieFile)
- }
-
- return new Promise((resolve, reject) => {
- console.log('Starting yt-dlp download:', { url, quality, savePath })
-
- const downloadProcess = spawn(ytDlpPath, args, {
- stdio: ['pipe', 'pipe', 'pipe'],
- cwd: process.cwd()
- })
-
- let output = ''
- let errorOutput = ''
- let downloadedFilename = null
- let downloadedFilePath = null
-
- // Enhanced progress parsing from yt-dlp output
- downloadProcess.stdout.on('data', (data) => {
- const chunk = data.toString()
- output += chunk
-
- // Parse different types of progress information
- const lines = chunk.split('\n')
-
- lines.forEach(line => {
- // Download progress: [download] 45.2% of 123.45MiB at 1.23MiB/s ETA 00:30
- const downloadMatch = line.match(/\[download\]\s+(\d+\.?\d*)%/)
- if (downloadMatch) {
- const progress = parseFloat(downloadMatch[1])
- // Adjust progress if conversion is required (download is only 70% of total)
- const adjustedProgress = requiresConversion ? Math.round(progress * 0.7) : progress
-
- event.sender.send('download-progress', {
- url,
- progress: adjustedProgress,
- status: 'downloading',
- stage: 'download'
- })
- }
-
- // Post-processing progress: [ffmpeg] Destination: filename.mp4
- const ffmpegMatch = line.match(/\[ffmpeg\]/)
- if (ffmpegMatch && !requiresConversion) {
- event.sender.send('download-progress', {
- url,
- progress: 95, // Assume 95% when post-processing starts
- status: 'converting',
- stage: 'postprocess'
- })
- }
-
- // Extract final filename: [download] Destination: filename.mp4
- const filenameMatch = line.match(/\[download\]\s+Destination:\s+(.+)/)
- if (filenameMatch) {
- downloadedFilename = path.basename(filenameMatch[1])
- downloadedFilePath = filenameMatch[1]
- }
-
- // Alternative filename extraction: [download] filename.mp4 has already been downloaded
- const alreadyDownloadedMatch = line.match(/\[download\]\s+(.+?)\s+has already been downloaded/)
- if (alreadyDownloadedMatch) {
- downloadedFilename = path.basename(alreadyDownloadedMatch[1])
- downloadedFilePath = alreadyDownloadedMatch[1]
- }
- })
- })
-
- downloadProcess.stderr.on('data', (data) => {
- const chunk = data.toString()
- errorOutput += chunk
-
- // Some yt-dlp messages come through stderr but aren't errors
- if (chunk.includes('WARNING') || chunk.includes('ERROR')) {
- console.warn('yt-dlp warning/error:', chunk.trim())
- }
- })
-
- downloadProcess.on('close', (code) => {
- console.log(`yt-dlp process exited with code ${code}`)
-
- if (code === 0) {
- // Send progress update - either final or intermediate if conversion required
- const finalProgress = requiresConversion ? 70 : 100
- const finalStatus = requiresConversion ? 'downloading' : 'completed'
-
- event.sender.send('download-progress', {
- url,
- progress: finalProgress,
- status: finalStatus,
- stage: requiresConversion ? 'download' : 'complete'
- })
- // Send desktop notification for successful download
- if (!requiresConversion) {
- notifyDownloadComplete(downloadedFilename || 'Video', true)
- }
-
- resolve({
- success: true,
- output,
- filename: downloadedFilename,
- filePath: downloadedFilePath,
- message: requiresConversion ? 'Download completed, starting conversion...' : 'Download completed successfully'
- })
- } else {
- // Enhanced error parsing with detailed user-friendly messages
- const errorInfo = parseDownloadError(errorOutput, code)
-
- // Send error notification
- notifyDownloadComplete(url, false, errorInfo.message)
-
- // Send error progress update
- event.sender.send('download-progress', {
- url,
- progress: 0,
- status: 'error',
- stage: 'error',
- error: errorInfo.message,
- errorCode: code,
- errorType: errorInfo.type
- })
-
- reject(new Error(errorInfo.message))
- }
- })
-
- downloadProcess.on('error', (error) => {
- console.error('Failed to start yt-dlp process:', error)
- reject(new Error(`Failed to start download process: ${error.message}`))
- })
- })
- }
- // Helper function to get yt-dlp format string for quality
- function getQualityFormat(quality) {
- const qualityMap = {
- '4K': 'best[height<=2160]',
- '1440p': 'best[height<=1440]',
- '1080p': 'best[height<=1080]',
- '720p': 'best[height<=720]',
- '480p': 'best[height<=480]',
- 'best': 'best'
- }
-
- return qualityMap[quality] || 'best[height<=720]'
- }
- // Format conversion handlers
- ipcMain.handle('cancel-conversion', async (event, conversionId) => {
- try {
- const cancelled = ffmpegConverter.cancelConversion(conversionId)
- return { success: cancelled, message: cancelled ? 'Conversion cancelled' : 'Conversion not found' }
- } catch (error) {
- console.error('Error cancelling conversion:', error)
- throw new Error(`Failed to cancel conversion: ${error.message}`)
- }
- })
- ipcMain.handle('cancel-all-conversions', async (event) => {
- try {
- const cancelledCount = ffmpegConverter.cancelAllConversions()
- return {
- success: true,
- cancelledCount,
- message: `Cancelled ${cancelledCount} active conversions`
- }
- } catch (error) {
- console.error('Error cancelling all conversions:', error)
- throw new Error(`Failed to cancel conversions: ${error.message}`)
- }
- })
- ipcMain.handle('get-active-conversions', async (event) => {
- try {
- const activeConversions = ffmpegConverter.getActiveConversions()
- return { success: true, conversions: activeConversions }
- } catch (error) {
- console.error('Error getting active conversions:', error)
- throw new Error(`Failed to get active conversions: ${error.message}`)
- }
- })
- // Download Manager IPC Handlers
- ipcMain.handle('get-download-stats', async (event) => {
- try {
- const stats = downloadManager.getStats()
- return { success: true, stats }
- } catch (error) {
- console.error('Error getting download stats:', error)
- throw new Error(`Failed to get download stats: ${error.message}`)
- }
- })
- ipcMain.handle('cancel-download', async (event, videoId) => {
- try {
- const cancelled = downloadManager.cancelDownload(videoId)
- return {
- success: cancelled,
- message: cancelled ? 'Download cancelled' : 'Download not found in queue'
- }
- } catch (error) {
- console.error('Error cancelling download:', error)
- throw new Error(`Failed to cancel download: ${error.message}`)
- }
- })
- ipcMain.handle('cancel-all-downloads', async (event) => {
- try {
- const result = downloadManager.cancelAll()
- return {
- success: true,
- cancelled: result.cancelled,
- active: result.active,
- message: `Cancelled ${result.cancelled} queued downloads. ${result.active} downloads still active.`
- }
- } catch (error) {
- console.error('Error cancelling all downloads:', error)
- throw new Error(`Failed to cancel downloads: ${error.message}`)
- }
- })
- // Get video metadata with enhanced information extraction
- ipcMain.handle('get-video-metadata', async (event, url) => {
- const ytDlpPath = getBinaryPath('yt-dlp')
-
- if (!fs.existsSync(ytDlpPath)) {
- const errorInfo = handleBinaryMissing('yt-dlp')
- throw new Error(errorInfo.message)
- }
-
- if (!url || typeof url !== 'string') {
- throw new Error('Valid URL is required')
- }
-
- try {
- console.log('Fetching metadata for:', url)
-
- // Use enhanced yt-dlp options for metadata extraction
- const args = [
- '--dump-json',
- '--no-warnings',
- '--no-download',
- '--ignore-errors', // Continue on errors to get partial metadata
- url
- ]
-
- const output = await runCommand(ytDlpPath, args)
-
- if (!output.trim()) {
- throw new Error('No metadata returned from yt-dlp')
- }
-
- const metadata = JSON.parse(output)
-
- // Extract comprehensive metadata with fallbacks
- const result = {
- title: metadata.title || metadata.fulltitle || 'Unknown Title',
- duration: metadata.duration, // Send raw number, let renderer format it
- thumbnail: selectBestThumbnail(metadata.thumbnails) || metadata.thumbnail,
- uploader: metadata.uploader || metadata.channel || 'Unknown Uploader',
- uploadDate: formatUploadDate(metadata.upload_date),
- viewCount: formatViewCount(metadata.view_count),
- description: metadata.description ? metadata.description.substring(0, 500) : null,
- availableQualities: extractAvailableQualities(metadata.formats),
- filesize: formatFilesize(metadata.filesize || metadata.filesize_approx),
- platform: metadata.extractor_key || 'Unknown'
- }
-
- console.log('Metadata extracted successfully:', result.title)
- return result
-
- } catch (error) {
- console.error('Error extracting metadata:', error)
-
- // Provide more specific error messages
- if (error.message.includes('Video unavailable')) {
- throw new Error('Video is unavailable or has been removed')
- } else if (error.message.includes('Private video')) {
- throw new Error('Video is private and cannot be accessed')
- } else if (error.message.includes('Sign in')) {
- throw new Error('Age-restricted video - authentication required')
- } else if (error.message.includes('network')) {
- throw new Error('Network error - check your internet connection')
- } else {
- throw new Error(`Failed to get metadata: ${error.message}`)
- }
- }
- })
- // Extract all videos from a YouTube playlist
- ipcMain.handle('extract-playlist-videos', async (event, playlistUrl) => {
- const ytDlpPath = getBinaryPath('yt-dlp')
- if (!fs.existsSync(ytDlpPath)) {
- const errorInfo = handleBinaryMissing('yt-dlp')
- throw new Error(errorInfo.message)
- }
- if (!playlistUrl || typeof playlistUrl !== 'string') {
- throw new Error('Valid playlist URL is required')
- }
- // Verify it's a playlist URL
- const playlistPattern = /[?&]list=([\w\-]+)/
- const match = playlistUrl.match(playlistPattern)
- if (!match) {
- throw new Error('Invalid playlist URL format')
- }
- const playlistId = match[1]
- try {
- console.log('Extracting playlist videos:', playlistId)
- // Use yt-dlp to extract playlist information
- const args = [
- '--flat-playlist',
- '--dump-json',
- '--no-warnings',
- playlistUrl
- ]
- const output = await runCommand(ytDlpPath, args)
- if (!output.trim()) {
- throw new Error('No playlist data returned from yt-dlp')
- }
- // Parse JSON lines (one per video)
- const lines = output.trim().split('\n')
- const videos = []
- for (const line of lines) {
- try {
- const videoData = JSON.parse(line)
- // Extract essential video information
- videos.push({
- id: videoData.id,
- title: videoData.title || 'Unknown Title',
- url: videoData.url || `https://www.youtube.com/watch?v=${videoData.id}`,
- duration: videoData.duration || null,
- thumbnail: videoData.thumbnail || null,
- uploader: videoData.uploader || videoData.channel || null
- })
- } catch (parseError) {
- console.warn('Failed to parse playlist video:', parseError)
- // Continue processing other videos
- }
- }
- console.log(`Extracted ${videos.length} videos from playlist`)
- return {
- success: true,
- playlistId: playlistId,
- videoCount: videos.length,
- videos: videos
- }
- } catch (error) {
- console.error('Error extracting playlist:', error)
- if (error.message.includes('Playlist does not exist')) {
- throw new Error('Playlist not found or has been deleted')
- } else if (error.message.includes('Private')) {
- throw new Error('Playlist is private and cannot be accessed')
- } else {
- throw new Error(`Failed to extract playlist: ${error.message}`)
- }
- }
- })
- // Helper function to select the best thumbnail from available options
- function selectBestThumbnail(thumbnails) {
- if (!thumbnails || !Array.isArray(thumbnails)) {
- return null
- }
-
- // Prefer thumbnails in this order: maxresdefault, hqdefault, mqdefault, default
- const preferredIds = ['maxresdefault', 'hqdefault', 'mqdefault', 'default']
-
- for (const preferredId of preferredIds) {
- const thumbnail = thumbnails.find(t => t.id === preferredId)
- if (thumbnail && thumbnail.url) {
- return thumbnail.url
- }
- }
-
- // Fallback to the largest thumbnail by resolution
- const sortedThumbnails = thumbnails
- .filter(t => t.url && t.width && t.height)
- .sort((a, b) => (b.width * b.height) - (a.width * a.height))
-
- return sortedThumbnails.length > 0 ? sortedThumbnails[0].url : null
- }
- // Helper function to extract available video qualities
- function extractAvailableQualities(formats) {
- if (!formats || !Array.isArray(formats)) {
- return []
- }
-
- const qualities = new Set()
-
- formats.forEach(format => {
- if (format.height) {
- if (format.height >= 2160) qualities.add('4K')
- else if (format.height >= 1440) qualities.add('1440p')
- else if (format.height >= 1080) qualities.add('1080p')
- else if (format.height >= 720) qualities.add('720p')
- else if (format.height >= 480) qualities.add('480p')
- }
- })
-
- return Array.from(qualities).sort((a, b) => {
- const order = { '4K': 5, '1440p': 4, '1080p': 3, '720p': 2, '480p': 1 }
- return (order[b] || 0) - (order[a] || 0)
- })
- }
- // Helper function to format upload date
- function formatUploadDate(uploadDate) {
- if (!uploadDate) return null
-
- try {
- // yt-dlp returns dates in YYYYMMDD format
- const year = uploadDate.substring(0, 4)
- const month = uploadDate.substring(4, 6)
- const day = uploadDate.substring(6, 8)
-
- const date = new Date(`${year}-${month}-${day}`)
- return date.toLocaleDateString()
- } catch (error) {
- return null
- }
- }
- // Helper function to format view count
- function formatViewCount(viewCount) {
- if (!viewCount || typeof viewCount !== 'number') return null
-
- if (viewCount >= 1000000) {
- return `${(viewCount / 1000000).toFixed(1)}M views`
- } else if (viewCount >= 1000) {
- return `${(viewCount / 1000).toFixed(1)}K views`
- } else {
- return `${viewCount} views`
- }
- }
- // Helper function to format file size
- function formatFilesize(filesize) {
- if (!filesize || typeof filesize !== 'number') return null
-
- const units = ['B', 'KB', 'MB', 'GB']
- let size = filesize
- let unitIndex = 0
-
- while (size >= 1024 && unitIndex < units.length - 1) {
- size /= 1024
- unitIndex++
- }
-
- return `${size.toFixed(1)} ${units[unitIndex]}`
- }
- // Utility functions
- function getBinaryPath(binaryName) {
- const binariesPath = path.join(__dirname, '../binaries')
- const extension = process.platform === 'win32' ? '.exe' : ''
- return path.join(binariesPath, `${binaryName}${extension}`)
- }
- function runCommand(command, args) {
- return new Promise((resolve, reject) => {
- const process = spawn(command, args)
- let output = ''
- let error = ''
-
- process.stdout.on('data', (data) => {
- output += data.toString()
- })
-
- process.stderr.on('data', (data) => {
- error += data.toString()
- })
-
- process.on('close', (code) => {
- if (code === 0) {
- resolve(output)
- } else {
- reject(new Error(error))
- }
- })
- })
- }
- function formatDuration(seconds) {
- if (!seconds) return '--:--'
-
- const hours = Math.floor(seconds / 3600)
- const minutes = Math.floor((seconds % 3600) / 60)
- const secs = Math.floor(seconds % 60)
-
- if (hours > 0) {
- return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
- } else {
- return `${minutes}:${secs.toString().padStart(2, '0')}`
- }
- }
- /**
- * Convert video format using FFmpeg
- */
- async function convertVideoFormat(event, { url, inputPath, format, quality, savePath }) {
- if (!ffmpegConverter.isAvailable()) {
- throw new Error('FFmpeg binary not found - conversion not available')
- }
- // Generate output filename with appropriate extension
- const inputFilename = path.basename(inputPath, path.extname(inputPath))
- const outputExtension = getOutputExtension(format)
- const outputFilename = `${inputFilename}_${format.toLowerCase()}.${outputExtension}`
- const outputPath = path.join(savePath, outputFilename)
- console.log('Starting format conversion:', {
- inputPath, outputPath, format, quality
- })
- // Get video duration for progress calculation
- const duration = await ffmpegConverter.getVideoDuration(inputPath)
- // Set up progress callback
- const onProgress = (progressData) => {
- // Map conversion progress to 70-100% range (download was 0-70%)
- const adjustedProgress = 70 + Math.round(progressData.progress * 0.3)
-
- event.sender.send('download-progress', {
- url,
- progress: adjustedProgress,
- status: 'converting',
- stage: 'conversion',
- conversionSpeed: progressData.speed
- })
- }
- try {
- // Start conversion
- event.sender.send('download-progress', {
- url,
- progress: 70,
- status: 'converting',
- stage: 'conversion'
- })
- const result = await ffmpegConverter.convertVideo({
- inputPath,
- outputPath,
- format,
- quality,
- duration,
- onProgress
- })
- // Send final completion progress
- event.sender.send('download-progress', {
- url,
- progress: 100,
- status: 'completed',
- stage: 'complete'
- })
- // Send desktop notification for successful conversion
- notifyDownloadComplete(outputFilename, true)
- // Clean up original file if conversion successful
- try {
- fs.unlinkSync(inputPath)
- console.log('Cleaned up original file:', inputPath)
- } catch (cleanupError) {
- console.warn('Failed to clean up original file:', cleanupError.message)
- }
- return {
- success: true,
- filename: outputFilename,
- filePath: outputPath,
- fileSize: result.fileSize,
- message: 'Conversion completed successfully'
- }
- } catch (error) {
- console.error('Format conversion failed:', error)
- throw new Error(`Format conversion failed: ${error.message}`)
- }
- }
- /**
- * Get output file extension for format
- */
- function getOutputExtension(format) {
- const extensionMap = {
- 'H264': 'mp4',
- 'ProRes': 'mov',
- 'DNxHR': 'mov',
- 'Audio only': 'm4a'
- }
- return extensionMap[format] || 'mp4'
- }
- /**
- * Parse download errors and provide user-friendly messages
- */
- function parseDownloadError(errorOutput, exitCode) {
- const errorInfo = {
- type: 'unknown',
- message: 'Download failed with unknown error',
- suggestion: 'Please try again or check the video URL'
- }
- if (!errorOutput) {
- errorInfo.type = 'process'
- errorInfo.message = `Download process failed (exit code: ${exitCode})`
- return errorInfo
- }
- const lowerError = errorOutput.toLowerCase()
- // Network-related errors
- if (lowerError.includes('network') || lowerError.includes('connection') || lowerError.includes('timeout')) {
- errorInfo.type = 'network'
- errorInfo.message = 'Network connection error - check your internet connection'
- errorInfo.suggestion = 'Verify your internet connection and try again'
- }
- // Video availability errors
- else if (lowerError.includes('video unavailable') || lowerError.includes('private video') || lowerError.includes('removed')) {
- errorInfo.type = 'availability'
- errorInfo.message = 'Video is unavailable, private, or has been removed'
- errorInfo.suggestion = 'Check if the video URL is correct and publicly accessible'
- }
- // Age restriction errors
- else if (lowerError.includes('sign in') || lowerError.includes('age') || lowerError.includes('restricted')) {
- errorInfo.type = 'age_restricted'
- errorInfo.message = 'Age-restricted video - authentication required'
- errorInfo.suggestion = 'Use a cookie file from your browser to access age-restricted content'
- }
- // Format/quality errors
- else if (lowerError.includes('format') || lowerError.includes('quality') || lowerError.includes('resolution')) {
- errorInfo.type = 'format'
- errorInfo.message = 'Requested video quality/format not available'
- errorInfo.suggestion = 'Try a different quality setting or use "Best Available"'
- }
- // Permission/disk space errors
- else if (lowerError.includes('permission') || lowerError.includes('access') || lowerError.includes('denied')) {
- errorInfo.type = 'permission'
- errorInfo.message = 'Permission denied - cannot write to download directory'
- errorInfo.suggestion = 'Check folder permissions or choose a different download location'
- }
- else if (lowerError.includes('space') || lowerError.includes('disk full') || lowerError.includes('no space')) {
- errorInfo.type = 'disk_space'
- errorInfo.message = 'Insufficient disk space for download'
- errorInfo.suggestion = 'Free up disk space or choose a different download location'
- }
- // Geo-blocking errors
- else if (lowerError.includes('geo') || lowerError.includes('region') || lowerError.includes('country')) {
- errorInfo.type = 'geo_blocked'
- errorInfo.message = 'Video not available in your region'
- errorInfo.suggestion = 'This video is geo-blocked in your location'
- }
- // Rate limiting
- else if (lowerError.includes('rate') || lowerError.includes('limit') || lowerError.includes('too many')) {
- errorInfo.type = 'rate_limit'
- errorInfo.message = 'Rate limited - too many requests'
- errorInfo.suggestion = 'Wait a few minutes before trying again'
- }
- // Extract specific error message if available
- else if (errorOutput.trim()) {
- const lines = errorOutput.trim().split('\n')
- const errorLines = lines.filter(line =>
- line.includes('ERROR') ||
- line.includes('error') ||
- line.includes('failed') ||
- line.includes('unable')
- )
-
- if (errorLines.length > 0) {
- const lastErrorLine = errorLines[errorLines.length - 1]
- // Clean up the error message
- let cleanMessage = lastErrorLine
- .replace(/^.*ERROR[:\s]*/i, '')
- .replace(/^.*error[:\s]*/i, '')
- .replace(/^\[.*?\]\s*/, '')
- .trim()
-
- if (cleanMessage && cleanMessage.length < 200) {
- errorInfo.message = cleanMessage
- errorInfo.type = 'specific'
- }
- }
- }
- return errorInfo
- }
- /**
- * Send desktop notification for download completion
- */
- function notifyDownloadComplete(filename, success, errorMessage = null) {
- try {
- const notificationOptions = {
- title: success ? 'Download Complete' : 'Download Failed',
- message: success
- ? `Successfully downloaded: ${filename}`
- : `Failed to download: ${errorMessage || 'Unknown error'}`,
- icon: path.join(__dirname, '../assets/icons/logo.png'),
- sound: true,
- timeout: success ? 5 : 10 // Show error notifications longer
- }
- // Use native Electron notifications if supported
- if (Notification.isSupported()) {
- const notification = new Notification({
- title: notificationOptions.title,
- body: notificationOptions.message,
- icon: notificationOptions.icon,
- silent: false
- })
- notification.show()
- // Auto-close success notifications after 5 seconds
- if (success) {
- setTimeout(() => {
- notification.close()
- }, 5000)
- }
- } else {
- // Fallback to node-notifier
- notifier.notify(notificationOptions, (err) => {
- if (err) {
- console.error('Notification error:', err)
- }
- })
- }
- } catch (error) {
- console.error('Failed to send notification:', error)
- }
- }
- /**
- * Enhanced binary missing error handler
- */
- function handleBinaryMissing(binaryName) {
- const errorInfo = {
- title: 'Missing Dependency',
- message: `${binaryName} binary not found`,
- detail: `The ${binaryName} binary is required for video downloads but was not found in the binaries directory. Please ensure all dependencies are properly installed.`,
- suggestion: binaryName === 'yt-dlp'
- ? 'yt-dlp is required for downloading videos from YouTube and other platforms'
- : 'ffmpeg is required for video format conversion and processing'
- }
- // Send notification about missing binary
- notifier.notify({
- title: errorInfo.title,
- message: `${errorInfo.message}. Please check the application setup.`,
- icon: path.join(__dirname, '../assets/icons/logo.png'),
- sound: true,
- timeout: 10
- })
- return errorInfo
- }
|