main.js 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406
  1. const { app, BrowserWindow, ipcMain, dialog, shell, Notification } = require('electron')
  2. const path = require('path')
  3. const fs = require('fs')
  4. const { spawn } = require('child_process')
  5. const notifier = require('node-notifier')
  6. const ffmpegConverter = require('../scripts/utils/ffmpeg-converter')
  7. const DownloadManager = require('./download-manager')
  8. // Keep a global reference of the window object
  9. let mainWindow
  10. // Initialize download manager
  11. const downloadManager = new DownloadManager()
  12. function createWindow() {
  13. // Create the browser window
  14. mainWindow = new BrowserWindow({
  15. width: 1200,
  16. height: 800,
  17. minWidth: 800,
  18. minHeight: 600,
  19. titleBarStyle: 'hiddenInset', // macOS style - hides title bar but keeps native traffic lights
  20. webPreferences: {
  21. nodeIntegration: false,
  22. contextIsolation: true,
  23. enableRemoteModule: false,
  24. preload: path.join(__dirname, 'preload.js')
  25. },
  26. icon: path.join(__dirname, '../assets/icons/logo.png'), // App icon
  27. show: false // Don't show until ready
  28. })
  29. // Load the app
  30. mainWindow.loadFile(path.join(__dirname, '../index.html'))
  31. // Show window when ready to prevent visual flash
  32. mainWindow.once('ready-to-show', () => {
  33. mainWindow.show()
  34. })
  35. // Open DevTools in development
  36. if (process.argv.includes('--dev')) {
  37. mainWindow.webContents.openDevTools()
  38. }
  39. // Handle window closed
  40. mainWindow.on('closed', () => {
  41. mainWindow = null
  42. })
  43. // Handle external links
  44. mainWindow.webContents.setWindowOpenHandler(({ url }) => {
  45. shell.openExternal(url)
  46. return { action: 'deny' }
  47. })
  48. }
  49. // App event handlers
  50. app.whenReady().then(createWindow)
  51. app.on('window-all-closed', () => {
  52. if (process.platform !== 'darwin') {
  53. app.quit()
  54. }
  55. })
  56. app.on('activate', () => {
  57. if (BrowserWindow.getAllWindows().length === 0) {
  58. createWindow()
  59. }
  60. })
  61. // IPC handlers for file system operations
  62. ipcMain.handle('select-save-directory', async () => {
  63. try {
  64. const result = await dialog.showOpenDialog(mainWindow, {
  65. properties: ['openDirectory', 'createDirectory'],
  66. title: 'Select Download Directory',
  67. buttonLabel: 'Select Folder',
  68. message: 'Choose where to save downloaded videos'
  69. })
  70. if (!result.canceled && result.filePaths.length > 0) {
  71. const selectedPath = result.filePaths[0]
  72. // Verify directory is writable
  73. try {
  74. await fs.promises.access(selectedPath, fs.constants.W_OK)
  75. console.log('Selected save directory:', selectedPath)
  76. return { success: true, path: selectedPath }
  77. } catch (error) {
  78. console.error('Directory not writable:', error)
  79. return {
  80. success: false,
  81. error: 'Selected directory is not writable. Please choose a different location.'
  82. }
  83. }
  84. }
  85. return { success: false, error: 'No directory selected' }
  86. } catch (error) {
  87. console.error('Error selecting save directory:', error)
  88. return {
  89. success: false,
  90. error: `Failed to open directory selector: ${error.message}`
  91. }
  92. }
  93. })
  94. // Create directory with recursive option
  95. ipcMain.handle('create-directory', async (event, dirPath) => {
  96. try {
  97. if (!dirPath || typeof dirPath !== 'string') {
  98. return { success: false, error: 'Invalid directory path' }
  99. }
  100. // Expand ~ to home directory
  101. const expandedPath = dirPath.startsWith('~')
  102. ? path.join(require('os').homedir(), dirPath.slice(1))
  103. : dirPath
  104. // Create directory recursively
  105. await fs.promises.mkdir(expandedPath, { recursive: true })
  106. console.log('Directory created successfully:', expandedPath)
  107. return { success: true, path: expandedPath }
  108. } catch (error) {
  109. console.error('Error creating directory:', error)
  110. return {
  111. success: false,
  112. error: `Failed to create directory: ${error.message}`
  113. }
  114. }
  115. })
  116. ipcMain.handle('select-cookie-file', async () => {
  117. try {
  118. const result = await dialog.showOpenDialog(mainWindow, {
  119. properties: ['openFile'],
  120. filters: [
  121. { name: 'Cookie Files', extensions: ['txt'] },
  122. { name: 'Netscape Cookie Files', extensions: ['cookies'] },
  123. { name: 'All Files', extensions: ['*'] }
  124. ],
  125. title: 'Select Cookie File',
  126. buttonLabel: 'Select Cookie File',
  127. message: 'Choose a cookie file for age-restricted content'
  128. })
  129. if (!result.canceled && result.filePaths.length > 0) {
  130. const selectedPath = result.filePaths[0]
  131. // Verify file exists and is readable
  132. try {
  133. await fs.promises.access(selectedPath, fs.constants.R_OK)
  134. const stats = await fs.promises.stat(selectedPath)
  135. if (stats.size === 0) {
  136. return {
  137. success: false,
  138. error: 'Selected cookie file is empty. Please choose a valid cookie file.'
  139. }
  140. }
  141. console.log('Selected cookie file:', selectedPath)
  142. return { success: true, path: selectedPath }
  143. } catch (error) {
  144. console.error('Cookie file not accessible:', error)
  145. return {
  146. success: false,
  147. error: 'Selected cookie file is not readable. Please check file permissions.'
  148. }
  149. }
  150. }
  151. return { success: false, error: 'No cookie file selected' }
  152. } catch (error) {
  153. console.error('Error selecting cookie file:', error)
  154. return {
  155. success: false,
  156. error: `Failed to open file selector: ${error.message}`
  157. }
  158. }
  159. })
  160. // Desktop notification system
  161. ipcMain.handle('show-notification', async (event, options) => {
  162. try {
  163. const notificationOptions = {
  164. title: options.title || 'GrabZilla',
  165. message: options.message || '',
  166. icon: options.icon || path.join(__dirname, '../assets/icons/logo.png'),
  167. sound: options.sound !== false, // Default to true
  168. wait: options.wait || false,
  169. timeout: options.timeout || 5
  170. }
  171. // Use native Electron notifications if supported, fallback to node-notifier
  172. if (Notification.isSupported()) {
  173. const notification = new Notification({
  174. title: notificationOptions.title,
  175. body: notificationOptions.message,
  176. icon: notificationOptions.icon,
  177. silent: !notificationOptions.sound
  178. })
  179. notification.show()
  180. if (options.onClick && typeof options.onClick === 'function') {
  181. notification.on('click', options.onClick)
  182. }
  183. return { success: true, method: 'electron' }
  184. } else {
  185. // Fallback to node-notifier for older systems
  186. return new Promise((resolve) => {
  187. notifier.notify(notificationOptions, (err, response) => {
  188. if (err) {
  189. console.error('Notification error:', err)
  190. resolve({ success: false, error: err.message })
  191. } else {
  192. resolve({ success: true, method: 'node-notifier', response })
  193. }
  194. })
  195. })
  196. }
  197. } catch (error) {
  198. console.error('Failed to show notification:', error)
  199. return { success: false, error: error.message }
  200. }
  201. })
  202. // Error dialog system
  203. ipcMain.handle('show-error-dialog', async (event, options) => {
  204. try {
  205. const dialogOptions = {
  206. type: 'error',
  207. title: options.title || 'Error',
  208. message: options.message || 'An error occurred',
  209. detail: options.detail || '',
  210. buttons: options.buttons || ['OK'],
  211. defaultId: options.defaultId || 0,
  212. cancelId: options.cancelId || 0
  213. }
  214. const result = await dialog.showMessageBox(mainWindow, dialogOptions)
  215. return { success: true, response: result.response, checkboxChecked: result.checkboxChecked }
  216. } catch (error) {
  217. console.error('Failed to show error dialog:', error)
  218. return { success: false, error: error.message }
  219. }
  220. })
  221. // Info dialog system
  222. ipcMain.handle('show-info-dialog', async (event, options) => {
  223. try {
  224. const dialogOptions = {
  225. type: 'info',
  226. title: options.title || 'Information',
  227. message: options.message || '',
  228. detail: options.detail || '',
  229. buttons: options.buttons || ['OK'],
  230. defaultId: options.defaultId || 0
  231. }
  232. const result = await dialog.showMessageBox(mainWindow, dialogOptions)
  233. return { success: true, response: result.response }
  234. } catch (error) {
  235. console.error('Failed to show info dialog:', error)
  236. return { success: false, error: error.message }
  237. }
  238. })
  239. // Binary dependency management
  240. ipcMain.handle('check-binary-dependencies', async () => {
  241. const binariesPath = path.join(__dirname, '../binaries')
  242. const results = {
  243. binariesPath,
  244. ytDlp: { available: false, path: null, error: null },
  245. ffmpeg: { available: false, path: null, error: null },
  246. allAvailable: false
  247. }
  248. try {
  249. // Ensure binaries directory exists
  250. if (!fs.existsSync(binariesPath)) {
  251. const error = `Binaries directory not found: ${binariesPath}`
  252. console.error(error)
  253. results.ytDlp.error = error
  254. results.ffmpeg.error = error
  255. return results
  256. }
  257. // Check yt-dlp
  258. const ytDlpPath = getBinaryPath('yt-dlp')
  259. results.ytDlp.path = ytDlpPath
  260. if (fs.existsSync(ytDlpPath)) {
  261. try {
  262. // Test if binary is executable
  263. await fs.promises.access(ytDlpPath, fs.constants.X_OK)
  264. results.ytDlp.available = true
  265. console.log('yt-dlp binary found and executable:', ytDlpPath)
  266. } catch (error) {
  267. results.ytDlp.error = 'yt-dlp binary exists but is not executable'
  268. console.error(results.ytDlp.error, error)
  269. }
  270. } else {
  271. results.ytDlp.error = 'yt-dlp binary not found'
  272. console.error(results.ytDlp.error, ytDlpPath)
  273. }
  274. // Check ffmpeg
  275. const ffmpegPath = getBinaryPath('ffmpeg')
  276. results.ffmpeg.path = ffmpegPath
  277. if (fs.existsSync(ffmpegPath)) {
  278. try {
  279. // Test if binary is executable
  280. await fs.promises.access(ffmpegPath, fs.constants.X_OK)
  281. results.ffmpeg.available = true
  282. console.log('ffmpeg binary found and executable:', ffmpegPath)
  283. } catch (error) {
  284. results.ffmpeg.error = 'ffmpeg binary exists but is not executable'
  285. console.error(results.ffmpeg.error, error)
  286. }
  287. } else {
  288. results.ffmpeg.error = 'ffmpeg binary not found'
  289. console.error(results.ffmpeg.error, ffmpegPath)
  290. }
  291. results.allAvailable = results.ytDlp.available && results.ffmpeg.available
  292. return results
  293. } catch (error) {
  294. console.error('Error checking binary dependencies:', error)
  295. results.ytDlp.error = error.message
  296. results.ffmpeg.error = error.message
  297. return results
  298. }
  299. })
  300. // Version checking cache (1-hour duration)
  301. let versionCache = {
  302. ytdlp: { latestVersion: null, timestamp: 0 },
  303. ffmpeg: { latestVersion: null, timestamp: 0 }
  304. }
  305. const CACHE_DURATION = 1000 * 60 * 60 // 1 hour
  306. /**
  307. * Compare two version strings
  308. * @param {string} v1 - First version (e.g., "2024.01.15")
  309. * @param {string} v2 - Second version
  310. * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal
  311. */
  312. function compareVersions(v1, v2) {
  313. if (!v1 || !v2) return 0
  314. // Remove non-numeric characters and split
  315. const parts1 = v1.replace(/[^0-9.]/g, '').split('.').map(Number)
  316. const parts2 = v2.replace(/[^0-9.]/g, '').split('.').map(Number)
  317. for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
  318. const p1 = parts1[i] || 0
  319. const p2 = parts2[i] || 0
  320. if (p1 > p2) return 1
  321. if (p1 < p2) return -1
  322. }
  323. return 0
  324. }
  325. /**
  326. * Get cached version or fetch from API
  327. * @param {string} key - Cache key ('ytdlp' or 'ffmpeg')
  328. * @param {Function} fetchFn - Function to fetch latest version
  329. * @returns {Promise<string|null>} Latest version or null
  330. */
  331. async function getCachedVersion(key, fetchFn) {
  332. const cached = versionCache[key]
  333. const now = Date.now()
  334. // Return cached if still valid
  335. if (cached.latestVersion && (now - cached.timestamp) < CACHE_DURATION) {
  336. return cached.latestVersion
  337. }
  338. // Fetch new version
  339. try {
  340. const version = await fetchFn()
  341. versionCache[key] = { latestVersion: version, timestamp: now }
  342. return version
  343. } catch (error) {
  344. console.warn(`Failed to fetch latest ${key} version:`, error.message)
  345. // Return cached even if expired on error
  346. return cached.latestVersion
  347. }
  348. }
  349. /**
  350. * Check latest yt-dlp version from GitHub API
  351. * @returns {Promise<string>} Latest version tag
  352. */
  353. async function checkLatestYtDlpVersion() {
  354. const https = require('https')
  355. return new Promise((resolve, reject) => {
  356. const options = {
  357. hostname: 'api.github.com',
  358. path: '/repos/yt-dlp/yt-dlp/releases/latest',
  359. method: 'GET',
  360. headers: {
  361. 'User-Agent': 'GrabZilla-App',
  362. 'Accept': 'application/vnd.github.v3+json'
  363. },
  364. timeout: 5000
  365. }
  366. const req = https.request(options, (res) => {
  367. let data = ''
  368. res.on('data', (chunk) => {
  369. data += chunk
  370. })
  371. res.on('end', () => {
  372. try {
  373. if (res.statusCode === 200) {
  374. const json = JSON.parse(data)
  375. // tag_name format: "2024.01.15"
  376. resolve(json.tag_name || null)
  377. } else if (res.statusCode === 403) {
  378. // Rate limited
  379. reject(new Error('GitHub API rate limit exceeded'))
  380. } else {
  381. reject(new Error(`GitHub API returned ${res.statusCode}`))
  382. }
  383. } catch (error) {
  384. reject(error)
  385. }
  386. })
  387. })
  388. req.on('error', reject)
  389. req.on('timeout', () => {
  390. req.destroy()
  391. reject(new Error('GitHub API request timeout'))
  392. })
  393. req.end()
  394. })
  395. }
  396. // Binary management
  397. ipcMain.handle('check-binary-versions', async () => {
  398. const binariesPath = path.join(__dirname, '../binaries')
  399. const results = {}
  400. let hasMissingBinaries = false
  401. try {
  402. // Ensure binaries directory exists
  403. if (!fs.existsSync(binariesPath)) {
  404. console.warn('Binaries directory not found:', binariesPath)
  405. hasMissingBinaries = true
  406. return { ytDlp: { available: false }, ffmpeg: { available: false } }
  407. }
  408. // Check yt-dlp version
  409. const ytDlpPath = getBinaryPath('yt-dlp')
  410. if (fs.existsSync(ytDlpPath)) {
  411. const ytDlpVersion = await runCommand(ytDlpPath, ['--version'])
  412. results.ytDlp = {
  413. version: ytDlpVersion.trim(),
  414. available: true,
  415. updateAvailable: false,
  416. latestVersion: null
  417. }
  418. // Check for updates (non-blocking)
  419. try {
  420. const latestVersion = await getCachedVersion('ytdlp', checkLatestYtDlpVersion)
  421. if (latestVersion) {
  422. results.ytDlp.latestVersion = latestVersion
  423. results.ytDlp.updateAvailable = compareVersions(latestVersion, results.ytDlp.version) > 0
  424. }
  425. } catch (updateError) {
  426. console.warn('Could not check for yt-dlp updates:', updateError.message)
  427. // Continue without update info
  428. }
  429. } else {
  430. results.ytDlp = { available: false }
  431. hasMissingBinaries = true
  432. }
  433. // Check ffmpeg version
  434. const ffmpegPath = getBinaryPath('ffmpeg')
  435. if (fs.existsSync(ffmpegPath)) {
  436. const ffmpegVersion = await runCommand(ffmpegPath, ['-version'])
  437. const versionMatch = ffmpegVersion.match(/ffmpeg version ([^\s]+)/)
  438. results.ffmpeg = {
  439. version: versionMatch ? versionMatch[1] : 'unknown',
  440. available: true,
  441. updateAvailable: false,
  442. latestVersion: null
  443. }
  444. // Note: ffmpeg doesn't have easy API for latest version
  445. // Skip update checking for ffmpeg for now
  446. } else {
  447. results.ffmpeg = { available: false }
  448. hasMissingBinaries = true
  449. }
  450. // Show native notification if binaries are missing
  451. if (hasMissingBinaries && mainWindow) {
  452. const missingList = [];
  453. if (!results.ytDlp || !results.ytDlp.available) missingList.push('yt-dlp');
  454. if (!results.ffmpeg || !results.ffmpeg.available) missingList.push('ffmpeg');
  455. console.error(`❌ Missing binaries detected: ${missingList.join(', ')}`);
  456. // Send notification via IPC to show dialog
  457. mainWindow.webContents.send('binaries-missing', {
  458. missing: missingList,
  459. message: `Required binaries missing: ${missingList.join(', ')}`
  460. });
  461. }
  462. } catch (error) {
  463. console.error('Error checking binary versions:', error)
  464. // Return safe defaults on error
  465. results.ytDlp = results.ytDlp || { available: false }
  466. results.ffmpeg = results.ffmpeg || { available: false }
  467. }
  468. return results
  469. })
  470. // Video download handler with format conversion integration (uses DownloadManager for parallel processing)
  471. ipcMain.handle('download-video', async (event, { videoId, url, quality, format, savePath, cookieFile }) => {
  472. const ytDlpPath = getBinaryPath('yt-dlp')
  473. const ffmpegPath = getBinaryPath('ffmpeg')
  474. // Validate binaries exist before attempting download
  475. if (!fs.existsSync(ytDlpPath)) {
  476. const error = 'yt-dlp binary not found. Please run "npm run setup" to download required binaries.'
  477. console.error('❌', error)
  478. throw new Error(error)
  479. }
  480. // Check ffmpeg if format conversion is required
  481. const requiresConversion = format && format !== 'None'
  482. if (requiresConversion && !fs.existsSync(ffmpegPath)) {
  483. const error = 'ffmpeg binary not found. Required for format conversion. Please run "npm run setup".'
  484. console.error('❌', error)
  485. throw new Error(error)
  486. }
  487. // Validate inputs
  488. if (!videoId || !url || !quality || !savePath) {
  489. throw new Error('Missing required parameters: videoId, url, quality, or savePath')
  490. }
  491. // Check if format conversion is required (we already validated ffmpeg exists above if needed)
  492. const requiresConversionCheck = format && format !== 'None' && ffmpegConverter.isAvailable()
  493. console.log('Adding download to queue:', {
  494. videoId, url, quality, format, savePath, requiresConversion: requiresConversionCheck
  495. })
  496. // Define download function
  497. const downloadFn = async ({ url, quality, format, savePath, cookieFile }) => {
  498. try {
  499. // Step 1: Download video with yt-dlp
  500. const downloadResult = await downloadWithYtDlp(event, {
  501. url, quality, savePath, cookieFile, requiresConversion: requiresConversionCheck
  502. })
  503. // Step 2: Convert format if required
  504. if (requiresConversionCheck && downloadResult.success) {
  505. const conversionResult = await convertVideoFormat(event, {
  506. url,
  507. inputPath: downloadResult.filePath,
  508. format,
  509. quality,
  510. savePath
  511. })
  512. return {
  513. success: true,
  514. filename: conversionResult.filename,
  515. originalFile: downloadResult.filename,
  516. convertedFile: conversionResult.filename,
  517. message: 'Download and conversion completed successfully'
  518. }
  519. }
  520. return downloadResult
  521. } catch (error) {
  522. console.error('Download/conversion process failed:', error)
  523. throw error
  524. }
  525. }
  526. // Add to download manager queue
  527. return await downloadManager.addDownload({
  528. videoId,
  529. url,
  530. quality,
  531. format,
  532. savePath,
  533. cookieFile,
  534. downloadFn
  535. })
  536. })
  537. /**
  538. * Download video using yt-dlp
  539. */
  540. async function downloadWithYtDlp(event, { url, quality, savePath, cookieFile, requiresConversion }) {
  541. const ytDlpPath = getBinaryPath('yt-dlp')
  542. // Build yt-dlp arguments
  543. const args = [
  544. '--newline', // Force progress on new lines for better parsing
  545. '--no-warnings', // Reduce noise in output
  546. '-f', getQualityFormat(quality),
  547. '-o', path.join(savePath, '%(title)s.%(ext)s'),
  548. url
  549. ]
  550. // Add cookie file if provided
  551. if (cookieFile && fs.existsSync(cookieFile)) {
  552. args.unshift('--cookies', cookieFile)
  553. }
  554. return new Promise((resolve, reject) => {
  555. console.log('Starting yt-dlp download:', { url, quality, savePath })
  556. const downloadProcess = spawn(ytDlpPath, args, {
  557. stdio: ['pipe', 'pipe', 'pipe'],
  558. cwd: process.cwd()
  559. })
  560. let output = ''
  561. let errorOutput = ''
  562. let downloadedFilename = null
  563. let downloadedFilePath = null
  564. // Enhanced progress parsing from yt-dlp output
  565. downloadProcess.stdout.on('data', (data) => {
  566. const chunk = data.toString()
  567. output += chunk
  568. // Parse different types of progress information
  569. const lines = chunk.split('\n')
  570. lines.forEach(line => {
  571. // Download progress: [download] 45.2% of 123.45MiB at 1.23MiB/s ETA 00:30
  572. const downloadMatch = line.match(/\[download\]\s+(\d+\.?\d*)%/)
  573. if (downloadMatch) {
  574. const progress = parseFloat(downloadMatch[1])
  575. // Adjust progress if conversion is required (download is only 70% of total)
  576. const adjustedProgress = requiresConversion ? Math.round(progress * 0.7) : progress
  577. event.sender.send('download-progress', {
  578. url,
  579. progress: adjustedProgress,
  580. status: 'downloading',
  581. stage: 'download'
  582. })
  583. }
  584. // Post-processing progress: [ffmpeg] Destination: filename.mp4
  585. const ffmpegMatch = line.match(/\[ffmpeg\]/)
  586. if (ffmpegMatch && !requiresConversion) {
  587. event.sender.send('download-progress', {
  588. url,
  589. progress: 95, // Assume 95% when post-processing starts
  590. status: 'converting',
  591. stage: 'postprocess'
  592. })
  593. }
  594. // Extract final filename: [download] Destination: filename.mp4
  595. const filenameMatch = line.match(/\[download\]\s+Destination:\s+(.+)/)
  596. if (filenameMatch) {
  597. downloadedFilename = path.basename(filenameMatch[1])
  598. downloadedFilePath = filenameMatch[1]
  599. }
  600. // Alternative filename extraction: [download] filename.mp4 has already been downloaded
  601. const alreadyDownloadedMatch = line.match(/\[download\]\s+(.+?)\s+has already been downloaded/)
  602. if (alreadyDownloadedMatch) {
  603. downloadedFilename = path.basename(alreadyDownloadedMatch[1])
  604. downloadedFilePath = alreadyDownloadedMatch[1]
  605. }
  606. })
  607. })
  608. downloadProcess.stderr.on('data', (data) => {
  609. const chunk = data.toString()
  610. errorOutput += chunk
  611. // Some yt-dlp messages come through stderr but aren't errors
  612. if (chunk.includes('WARNING') || chunk.includes('ERROR')) {
  613. console.warn('yt-dlp warning/error:', chunk.trim())
  614. }
  615. })
  616. downloadProcess.on('close', (code) => {
  617. console.log(`yt-dlp process exited with code ${code}`)
  618. if (code === 0) {
  619. // Send progress update - either final or intermediate if conversion required
  620. const finalProgress = requiresConversion ? 70 : 100
  621. const finalStatus = requiresConversion ? 'downloading' : 'completed'
  622. event.sender.send('download-progress', {
  623. url,
  624. progress: finalProgress,
  625. status: finalStatus,
  626. stage: requiresConversion ? 'download' : 'complete'
  627. })
  628. // Send desktop notification for successful download
  629. if (!requiresConversion) {
  630. notifyDownloadComplete(downloadedFilename || 'Video', true)
  631. }
  632. resolve({
  633. success: true,
  634. output,
  635. filename: downloadedFilename,
  636. filePath: downloadedFilePath,
  637. message: requiresConversion ? 'Download completed, starting conversion...' : 'Download completed successfully'
  638. })
  639. } else {
  640. // Enhanced error parsing with detailed user-friendly messages
  641. const errorInfo = parseDownloadError(errorOutput, code)
  642. // Send error notification
  643. notifyDownloadComplete(url, false, errorInfo.message)
  644. // Send error progress update
  645. event.sender.send('download-progress', {
  646. url,
  647. progress: 0,
  648. status: 'error',
  649. stage: 'error',
  650. error: errorInfo.message,
  651. errorCode: code,
  652. errorType: errorInfo.type
  653. })
  654. reject(new Error(errorInfo.message))
  655. }
  656. })
  657. downloadProcess.on('error', (error) => {
  658. console.error('Failed to start yt-dlp process:', error)
  659. reject(new Error(`Failed to start download process: ${error.message}`))
  660. })
  661. })
  662. }
  663. // Helper function to get yt-dlp format string for quality
  664. function getQualityFormat(quality) {
  665. const qualityMap = {
  666. '4K': 'best[height<=2160]',
  667. '1440p': 'best[height<=1440]',
  668. '1080p': 'best[height<=1080]',
  669. '720p': 'best[height<=720]',
  670. '480p': 'best[height<=480]',
  671. 'best': 'best'
  672. }
  673. return qualityMap[quality] || 'best[height<=720]'
  674. }
  675. // Format conversion handlers
  676. ipcMain.handle('cancel-conversion', async (event, conversionId) => {
  677. try {
  678. const cancelled = ffmpegConverter.cancelConversion(conversionId)
  679. return { success: cancelled, message: cancelled ? 'Conversion cancelled' : 'Conversion not found' }
  680. } catch (error) {
  681. console.error('Error cancelling conversion:', error)
  682. throw new Error(`Failed to cancel conversion: ${error.message}`)
  683. }
  684. })
  685. ipcMain.handle('cancel-all-conversions', async (event) => {
  686. try {
  687. const cancelledCount = ffmpegConverter.cancelAllConversions()
  688. return {
  689. success: true,
  690. cancelledCount,
  691. message: `Cancelled ${cancelledCount} active conversions`
  692. }
  693. } catch (error) {
  694. console.error('Error cancelling all conversions:', error)
  695. throw new Error(`Failed to cancel conversions: ${error.message}`)
  696. }
  697. })
  698. ipcMain.handle('get-active-conversions', async (event) => {
  699. try {
  700. const activeConversions = ffmpegConverter.getActiveConversions()
  701. return { success: true, conversions: activeConversions }
  702. } catch (error) {
  703. console.error('Error getting active conversions:', error)
  704. throw new Error(`Failed to get active conversions: ${error.message}`)
  705. }
  706. })
  707. // Download Manager IPC Handlers
  708. ipcMain.handle('get-download-stats', async (event) => {
  709. try {
  710. const stats = downloadManager.getStats()
  711. return { success: true, stats }
  712. } catch (error) {
  713. console.error('Error getting download stats:', error)
  714. throw new Error(`Failed to get download stats: ${error.message}`)
  715. }
  716. })
  717. ipcMain.handle('cancel-download', async (event, videoId) => {
  718. try {
  719. const cancelled = downloadManager.cancelDownload(videoId)
  720. return {
  721. success: cancelled,
  722. message: cancelled ? 'Download cancelled' : 'Download not found in queue'
  723. }
  724. } catch (error) {
  725. console.error('Error cancelling download:', error)
  726. throw new Error(`Failed to cancel download: ${error.message}`)
  727. }
  728. })
  729. ipcMain.handle('cancel-all-downloads', async (event) => {
  730. try {
  731. const result = downloadManager.cancelAll()
  732. return {
  733. success: true,
  734. cancelled: result.cancelled,
  735. active: result.active,
  736. message: `Cancelled ${result.cancelled} queued downloads. ${result.active} downloads still active.`
  737. }
  738. } catch (error) {
  739. console.error('Error cancelling all downloads:', error)
  740. throw new Error(`Failed to cancel downloads: ${error.message}`)
  741. }
  742. })
  743. // Get video metadata with enhanced information extraction
  744. ipcMain.handle('get-video-metadata', async (event, url) => {
  745. const ytDlpPath = getBinaryPath('yt-dlp')
  746. if (!fs.existsSync(ytDlpPath)) {
  747. const errorInfo = handleBinaryMissing('yt-dlp')
  748. throw new Error(errorInfo.message)
  749. }
  750. if (!url || typeof url !== 'string') {
  751. throw new Error('Valid URL is required')
  752. }
  753. try {
  754. console.log('Fetching metadata for:', url)
  755. // Use enhanced yt-dlp options for metadata extraction
  756. const args = [
  757. '--dump-json',
  758. '--no-warnings',
  759. '--no-download',
  760. '--ignore-errors', // Continue on errors to get partial metadata
  761. url
  762. ]
  763. const output = await runCommand(ytDlpPath, args)
  764. if (!output.trim()) {
  765. throw new Error('No metadata returned from yt-dlp')
  766. }
  767. const metadata = JSON.parse(output)
  768. // Extract comprehensive metadata with fallbacks
  769. const result = {
  770. title: metadata.title || metadata.fulltitle || 'Unknown Title',
  771. duration: metadata.duration, // Send raw number, let renderer format it
  772. thumbnail: selectBestThumbnail(metadata.thumbnails) || metadata.thumbnail,
  773. uploader: metadata.uploader || metadata.channel || 'Unknown Uploader',
  774. uploadDate: formatUploadDate(metadata.upload_date),
  775. viewCount: formatViewCount(metadata.view_count),
  776. description: metadata.description ? metadata.description.substring(0, 500) : null,
  777. availableQualities: extractAvailableQualities(metadata.formats),
  778. filesize: formatFilesize(metadata.filesize || metadata.filesize_approx),
  779. platform: metadata.extractor_key || 'Unknown'
  780. }
  781. console.log('Metadata extracted successfully:', result.title)
  782. return result
  783. } catch (error) {
  784. console.error('Error extracting metadata:', error)
  785. // Provide more specific error messages
  786. if (error.message.includes('Video unavailable')) {
  787. throw new Error('Video is unavailable or has been removed')
  788. } else if (error.message.includes('Private video')) {
  789. throw new Error('Video is private and cannot be accessed')
  790. } else if (error.message.includes('Sign in')) {
  791. throw new Error('Age-restricted video - authentication required')
  792. } else if (error.message.includes('network')) {
  793. throw new Error('Network error - check your internet connection')
  794. } else {
  795. throw new Error(`Failed to get metadata: ${error.message}`)
  796. }
  797. }
  798. })
  799. // Extract all videos from a YouTube playlist
  800. ipcMain.handle('extract-playlist-videos', async (event, playlistUrl) => {
  801. const ytDlpPath = getBinaryPath('yt-dlp')
  802. if (!fs.existsSync(ytDlpPath)) {
  803. const errorInfo = handleBinaryMissing('yt-dlp')
  804. throw new Error(errorInfo.message)
  805. }
  806. if (!playlistUrl || typeof playlistUrl !== 'string') {
  807. throw new Error('Valid playlist URL is required')
  808. }
  809. // Verify it's a playlist URL
  810. const playlistPattern = /[?&]list=([\w\-]+)/
  811. const match = playlistUrl.match(playlistPattern)
  812. if (!match) {
  813. throw new Error('Invalid playlist URL format')
  814. }
  815. const playlistId = match[1]
  816. try {
  817. console.log('Extracting playlist videos:', playlistId)
  818. // Use yt-dlp to extract playlist information
  819. const args = [
  820. '--flat-playlist',
  821. '--dump-json',
  822. '--no-warnings',
  823. playlistUrl
  824. ]
  825. const output = await runCommand(ytDlpPath, args)
  826. if (!output.trim()) {
  827. throw new Error('No playlist data returned from yt-dlp')
  828. }
  829. // Parse JSON lines (one per video)
  830. const lines = output.trim().split('\n')
  831. const videos = []
  832. for (const line of lines) {
  833. try {
  834. const videoData = JSON.parse(line)
  835. // Extract essential video information
  836. videos.push({
  837. id: videoData.id,
  838. title: videoData.title || 'Unknown Title',
  839. url: videoData.url || `https://www.youtube.com/watch?v=${videoData.id}`,
  840. duration: videoData.duration || null,
  841. thumbnail: videoData.thumbnail || null,
  842. uploader: videoData.uploader || videoData.channel || null
  843. })
  844. } catch (parseError) {
  845. console.warn('Failed to parse playlist video:', parseError)
  846. // Continue processing other videos
  847. }
  848. }
  849. console.log(`Extracted ${videos.length} videos from playlist`)
  850. return {
  851. success: true,
  852. playlistId: playlistId,
  853. videoCount: videos.length,
  854. videos: videos
  855. }
  856. } catch (error) {
  857. console.error('Error extracting playlist:', error)
  858. if (error.message.includes('Playlist does not exist')) {
  859. throw new Error('Playlist not found or has been deleted')
  860. } else if (error.message.includes('Private')) {
  861. throw new Error('Playlist is private and cannot be accessed')
  862. } else {
  863. throw new Error(`Failed to extract playlist: ${error.message}`)
  864. }
  865. }
  866. })
  867. // Helper function to select the best thumbnail from available options
  868. function selectBestThumbnail(thumbnails) {
  869. if (!thumbnails || !Array.isArray(thumbnails)) {
  870. return null
  871. }
  872. // Prefer thumbnails in this order: maxresdefault, hqdefault, mqdefault, default
  873. const preferredIds = ['maxresdefault', 'hqdefault', 'mqdefault', 'default']
  874. for (const preferredId of preferredIds) {
  875. const thumbnail = thumbnails.find(t => t.id === preferredId)
  876. if (thumbnail && thumbnail.url) {
  877. return thumbnail.url
  878. }
  879. }
  880. // Fallback to the largest thumbnail by resolution
  881. const sortedThumbnails = thumbnails
  882. .filter(t => t.url && t.width && t.height)
  883. .sort((a, b) => (b.width * b.height) - (a.width * a.height))
  884. return sortedThumbnails.length > 0 ? sortedThumbnails[0].url : null
  885. }
  886. // Helper function to extract available video qualities
  887. function extractAvailableQualities(formats) {
  888. if (!formats || !Array.isArray(formats)) {
  889. return []
  890. }
  891. const qualities = new Set()
  892. formats.forEach(format => {
  893. if (format.height) {
  894. if (format.height >= 2160) qualities.add('4K')
  895. else if (format.height >= 1440) qualities.add('1440p')
  896. else if (format.height >= 1080) qualities.add('1080p')
  897. else if (format.height >= 720) qualities.add('720p')
  898. else if (format.height >= 480) qualities.add('480p')
  899. }
  900. })
  901. return Array.from(qualities).sort((a, b) => {
  902. const order = { '4K': 5, '1440p': 4, '1080p': 3, '720p': 2, '480p': 1 }
  903. return (order[b] || 0) - (order[a] || 0)
  904. })
  905. }
  906. // Helper function to format upload date
  907. function formatUploadDate(uploadDate) {
  908. if (!uploadDate) return null
  909. try {
  910. // yt-dlp returns dates in YYYYMMDD format
  911. const year = uploadDate.substring(0, 4)
  912. const month = uploadDate.substring(4, 6)
  913. const day = uploadDate.substring(6, 8)
  914. const date = new Date(`${year}-${month}-${day}`)
  915. return date.toLocaleDateString()
  916. } catch (error) {
  917. return null
  918. }
  919. }
  920. // Helper function to format view count
  921. function formatViewCount(viewCount) {
  922. if (!viewCount || typeof viewCount !== 'number') return null
  923. if (viewCount >= 1000000) {
  924. return `${(viewCount / 1000000).toFixed(1)}M views`
  925. } else if (viewCount >= 1000) {
  926. return `${(viewCount / 1000).toFixed(1)}K views`
  927. } else {
  928. return `${viewCount} views`
  929. }
  930. }
  931. // Helper function to format file size
  932. function formatFilesize(filesize) {
  933. if (!filesize || typeof filesize !== 'number') return null
  934. const units = ['B', 'KB', 'MB', 'GB']
  935. let size = filesize
  936. let unitIndex = 0
  937. while (size >= 1024 && unitIndex < units.length - 1) {
  938. size /= 1024
  939. unitIndex++
  940. }
  941. return `${size.toFixed(1)} ${units[unitIndex]}`
  942. }
  943. // Utility functions
  944. function getBinaryPath(binaryName) {
  945. const binariesPath = path.join(__dirname, '../binaries')
  946. const extension = process.platform === 'win32' ? '.exe' : ''
  947. return path.join(binariesPath, `${binaryName}${extension}`)
  948. }
  949. function runCommand(command, args) {
  950. return new Promise((resolve, reject) => {
  951. const process = spawn(command, args)
  952. let output = ''
  953. let error = ''
  954. process.stdout.on('data', (data) => {
  955. output += data.toString()
  956. })
  957. process.stderr.on('data', (data) => {
  958. error += data.toString()
  959. })
  960. process.on('close', (code) => {
  961. if (code === 0) {
  962. resolve(output)
  963. } else {
  964. reject(new Error(error))
  965. }
  966. })
  967. })
  968. }
  969. function formatDuration(seconds) {
  970. if (!seconds) return '--:--'
  971. const hours = Math.floor(seconds / 3600)
  972. const minutes = Math.floor((seconds % 3600) / 60)
  973. const secs = Math.floor(seconds % 60)
  974. if (hours > 0) {
  975. return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
  976. } else {
  977. return `${minutes}:${secs.toString().padStart(2, '0')}`
  978. }
  979. }
  980. /**
  981. * Convert video format using FFmpeg
  982. */
  983. async function convertVideoFormat(event, { url, inputPath, format, quality, savePath }) {
  984. if (!ffmpegConverter.isAvailable()) {
  985. throw new Error('FFmpeg binary not found - conversion not available')
  986. }
  987. // Generate output filename with appropriate extension
  988. const inputFilename = path.basename(inputPath, path.extname(inputPath))
  989. const outputExtension = getOutputExtension(format)
  990. const outputFilename = `${inputFilename}_${format.toLowerCase()}.${outputExtension}`
  991. const outputPath = path.join(savePath, outputFilename)
  992. console.log('Starting format conversion:', {
  993. inputPath, outputPath, format, quality
  994. })
  995. // Get video duration for progress calculation
  996. const duration = await ffmpegConverter.getVideoDuration(inputPath)
  997. // Set up progress callback
  998. const onProgress = (progressData) => {
  999. // Map conversion progress to 70-100% range (download was 0-70%)
  1000. const adjustedProgress = 70 + Math.round(progressData.progress * 0.3)
  1001. event.sender.send('download-progress', {
  1002. url,
  1003. progress: adjustedProgress,
  1004. status: 'converting',
  1005. stage: 'conversion',
  1006. conversionSpeed: progressData.speed
  1007. })
  1008. }
  1009. try {
  1010. // Start conversion
  1011. event.sender.send('download-progress', {
  1012. url,
  1013. progress: 70,
  1014. status: 'converting',
  1015. stage: 'conversion'
  1016. })
  1017. const result = await ffmpegConverter.convertVideo({
  1018. inputPath,
  1019. outputPath,
  1020. format,
  1021. quality,
  1022. duration,
  1023. onProgress
  1024. })
  1025. // Send final completion progress
  1026. event.sender.send('download-progress', {
  1027. url,
  1028. progress: 100,
  1029. status: 'completed',
  1030. stage: 'complete'
  1031. })
  1032. // Send desktop notification for successful conversion
  1033. notifyDownloadComplete(outputFilename, true)
  1034. // Clean up original file if conversion successful
  1035. try {
  1036. fs.unlinkSync(inputPath)
  1037. console.log('Cleaned up original file:', inputPath)
  1038. } catch (cleanupError) {
  1039. console.warn('Failed to clean up original file:', cleanupError.message)
  1040. }
  1041. return {
  1042. success: true,
  1043. filename: outputFilename,
  1044. filePath: outputPath,
  1045. fileSize: result.fileSize,
  1046. message: 'Conversion completed successfully'
  1047. }
  1048. } catch (error) {
  1049. console.error('Format conversion failed:', error)
  1050. throw new Error(`Format conversion failed: ${error.message}`)
  1051. }
  1052. }
  1053. /**
  1054. * Get output file extension for format
  1055. */
  1056. function getOutputExtension(format) {
  1057. const extensionMap = {
  1058. 'H264': 'mp4',
  1059. 'ProRes': 'mov',
  1060. 'DNxHR': 'mov',
  1061. 'Audio only': 'm4a'
  1062. }
  1063. return extensionMap[format] || 'mp4'
  1064. }
  1065. /**
  1066. * Parse download errors and provide user-friendly messages
  1067. */
  1068. function parseDownloadError(errorOutput, exitCode) {
  1069. const errorInfo = {
  1070. type: 'unknown',
  1071. message: 'Download failed with unknown error',
  1072. suggestion: 'Please try again or check the video URL'
  1073. }
  1074. if (!errorOutput) {
  1075. errorInfo.type = 'process'
  1076. errorInfo.message = `Download process failed (exit code: ${exitCode})`
  1077. return errorInfo
  1078. }
  1079. const lowerError = errorOutput.toLowerCase()
  1080. // Network-related errors
  1081. if (lowerError.includes('network') || lowerError.includes('connection') || lowerError.includes('timeout')) {
  1082. errorInfo.type = 'network'
  1083. errorInfo.message = 'Network connection error - check your internet connection'
  1084. errorInfo.suggestion = 'Verify your internet connection and try again'
  1085. }
  1086. // Video availability errors
  1087. else if (lowerError.includes('video unavailable') || lowerError.includes('private video') || lowerError.includes('removed')) {
  1088. errorInfo.type = 'availability'
  1089. errorInfo.message = 'Video is unavailable, private, or has been removed'
  1090. errorInfo.suggestion = 'Check if the video URL is correct and publicly accessible'
  1091. }
  1092. // Age restriction errors
  1093. else if (lowerError.includes('sign in') || lowerError.includes('age') || lowerError.includes('restricted')) {
  1094. errorInfo.type = 'age_restricted'
  1095. errorInfo.message = 'Age-restricted video - authentication required'
  1096. errorInfo.suggestion = 'Use a cookie file from your browser to access age-restricted content'
  1097. }
  1098. // Format/quality errors
  1099. else if (lowerError.includes('format') || lowerError.includes('quality') || lowerError.includes('resolution')) {
  1100. errorInfo.type = 'format'
  1101. errorInfo.message = 'Requested video quality/format not available'
  1102. errorInfo.suggestion = 'Try a different quality setting or use "Best Available"'
  1103. }
  1104. // Permission/disk space errors
  1105. else if (lowerError.includes('permission') || lowerError.includes('access') || lowerError.includes('denied')) {
  1106. errorInfo.type = 'permission'
  1107. errorInfo.message = 'Permission denied - cannot write to download directory'
  1108. errorInfo.suggestion = 'Check folder permissions or choose a different download location'
  1109. }
  1110. else if (lowerError.includes('space') || lowerError.includes('disk full') || lowerError.includes('no space')) {
  1111. errorInfo.type = 'disk_space'
  1112. errorInfo.message = 'Insufficient disk space for download'
  1113. errorInfo.suggestion = 'Free up disk space or choose a different download location'
  1114. }
  1115. // Geo-blocking errors
  1116. else if (lowerError.includes('geo') || lowerError.includes('region') || lowerError.includes('country')) {
  1117. errorInfo.type = 'geo_blocked'
  1118. errorInfo.message = 'Video not available in your region'
  1119. errorInfo.suggestion = 'This video is geo-blocked in your location'
  1120. }
  1121. // Rate limiting
  1122. else if (lowerError.includes('rate') || lowerError.includes('limit') || lowerError.includes('too many')) {
  1123. errorInfo.type = 'rate_limit'
  1124. errorInfo.message = 'Rate limited - too many requests'
  1125. errorInfo.suggestion = 'Wait a few minutes before trying again'
  1126. }
  1127. // Extract specific error message if available
  1128. else if (errorOutput.trim()) {
  1129. const lines = errorOutput.trim().split('\n')
  1130. const errorLines = lines.filter(line =>
  1131. line.includes('ERROR') ||
  1132. line.includes('error') ||
  1133. line.includes('failed') ||
  1134. line.includes('unable')
  1135. )
  1136. if (errorLines.length > 0) {
  1137. const lastErrorLine = errorLines[errorLines.length - 1]
  1138. // Clean up the error message
  1139. let cleanMessage = lastErrorLine
  1140. .replace(/^.*ERROR[:\s]*/i, '')
  1141. .replace(/^.*error[:\s]*/i, '')
  1142. .replace(/^\[.*?\]\s*/, '')
  1143. .trim()
  1144. if (cleanMessage && cleanMessage.length < 200) {
  1145. errorInfo.message = cleanMessage
  1146. errorInfo.type = 'specific'
  1147. }
  1148. }
  1149. }
  1150. return errorInfo
  1151. }
  1152. /**
  1153. * Send desktop notification for download completion
  1154. */
  1155. function notifyDownloadComplete(filename, success, errorMessage = null) {
  1156. try {
  1157. const notificationOptions = {
  1158. title: success ? 'Download Complete' : 'Download Failed',
  1159. message: success
  1160. ? `Successfully downloaded: ${filename}`
  1161. : `Failed to download: ${errorMessage || 'Unknown error'}`,
  1162. icon: path.join(__dirname, '../assets/icons/logo.png'),
  1163. sound: true,
  1164. timeout: success ? 5 : 10 // Show error notifications longer
  1165. }
  1166. // Use native Electron notifications if supported
  1167. if (Notification.isSupported()) {
  1168. const notification = new Notification({
  1169. title: notificationOptions.title,
  1170. body: notificationOptions.message,
  1171. icon: notificationOptions.icon,
  1172. silent: false
  1173. })
  1174. notification.show()
  1175. // Auto-close success notifications after 5 seconds
  1176. if (success) {
  1177. setTimeout(() => {
  1178. notification.close()
  1179. }, 5000)
  1180. }
  1181. } else {
  1182. // Fallback to node-notifier
  1183. notifier.notify(notificationOptions, (err) => {
  1184. if (err) {
  1185. console.error('Notification error:', err)
  1186. }
  1187. })
  1188. }
  1189. } catch (error) {
  1190. console.error('Failed to send notification:', error)
  1191. }
  1192. }
  1193. /**
  1194. * Enhanced binary missing error handler
  1195. */
  1196. function handleBinaryMissing(binaryName) {
  1197. const errorInfo = {
  1198. title: 'Missing Dependency',
  1199. message: `${binaryName} binary not found`,
  1200. detail: `The ${binaryName} binary is required for video downloads but was not found in the binaries directory. Please ensure all dependencies are properly installed.`,
  1201. suggestion: binaryName === 'yt-dlp'
  1202. ? 'yt-dlp is required for downloading videos from YouTube and other platforms'
  1203. : 'ffmpeg is required for video format conversion and processing'
  1204. }
  1205. // Send notification about missing binary
  1206. notifier.notify({
  1207. title: errorInfo.title,
  1208. message: `${errorInfo.message}. Please check the application setup.`,
  1209. icon: path.join(__dirname, '../assets/icons/logo.png'),
  1210. sound: true,
  1211. timeout: 10
  1212. })
  1213. return errorInfo
  1214. }