const { app, BrowserWindow, ipcMain, dialog } = require('electron'); const { join } = require('path'); const { writeFile, mkdir, access, unlink, readFile } = require('fs').promises; const { constants } = require('fs'); const Store = require('electron-store'); const path = require('path'); const fs = require('fs'); const https = require('https'); const FormData = require('form-data'); const { exec } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(exec); const { spawn } = require('child_process'); const store = new Store(); // Log file path const LOG_FILE_PATH = path.join(app.getPath('userData'), 'app.log'); // Ensure log file exists if (!fs.existsSync(LOG_FILE_PATH)) { fs.writeFileSync(LOG_FILE_PATH, ''); } // Log file management functions function saveLog(logEntry) { const logLine = JSON.stringify(logEntry) + '\n'; fs.appendFileSync(LOG_FILE_PATH, logLine); } function getLogs() { try { const logContent = fs.readFileSync(LOG_FILE_PATH, 'utf8'); return logContent .split('\n') .filter(line => line.trim()) .map(line => JSON.parse(line)); } catch (error) { console.error('Error reading logs:', error); return []; } } function clearLogs() { fs.writeFileSync(LOG_FILE_PATH, ''); } // Check if ffmpeg is installed async function checkFFmpeg() { try { // Look in extraResources path in packaged app, otherwise use relative dev path const basePath = app.isPackaged ? process.resourcesPath : join(__dirname, '..'); // Determine the correct subdirectory based on platform const platformBinDir = process.platform === 'win32' ? 'bin' : 'linux_bin'; const ffmpegBinaryName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'; const ffmpegPath = join(basePath, 'ffmpeg', platformBinDir, ffmpegBinaryName); console.log(`[Main] Checking for FFmpeg at: ${ffmpegPath}`); await fs.promises.access(ffmpegPath); console.log('[Main] FFmpeg found.'); return true; } catch (error) { console.error('[Main] FFmpeg check failed:', error); return false; } } // Install FFmpeg async function installFFmpeg() { try { console.log('Installing FFmpeg...'); // Create temp directory for download const tempDir = join(app.getPath('temp'), 'ffmpeg-install'); await mkdir(tempDir, { recursive: true }); let downloadUrl; let extractPath; let ffmpegDir = join(app.getPath('userData'), 'ffmpeg'); if (process.platform === 'win32') { downloadUrl = 'https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.zip'; const zipPath = join(tempDir, 'ffmpeg.zip'); console.log('Downloading FFmpeg from:', downloadUrl); await new Promise((resolve, reject) => { const file = fs.createWriteStream(zipPath); https.get(downloadUrl, (response) => { response.pipe(file); file.on('finish', () => { file.close(); resolve(null); }); }).on('error', (err) => { fs.unlink(zipPath, () => {}); reject(err); }); }); // Extract the zip file console.log('Extracting FFmpeg...'); extractPath = join(tempDir, 'extracted'); await mkdir(extractPath, { recursive: true }); const { stdout: extractOutput } = await execAsync(`powershell -command "Expand-Archive -Path '${zipPath}' -DestinationPath '${extractPath}' -Force"`); console.log('Extraction output:', extractOutput); // Copy FFmpeg files to a permanent location await mkdir(ffmpegDir, { recursive: true }); const ffmpegFiles = ['ffmpeg.exe', 'ffprobe.exe', 'ffplay.exe']; for (const file of ffmpegFiles) { const sourcePath = join(extractPath, 'ffmpeg-7.1.1-full_build', 'bin', file); const targetPath = join(ffmpegDir, file); await fs.promises.copyFile(sourcePath, targetPath); } } else if (process.platform === 'darwin') { // For macOS, we'll use Homebrew try { await execAsync('brew install ffmpeg'); ffmpegDir = '/usr/local/bin'; } catch (error) { console.error('Error installing FFmpeg via Homebrew:', error); throw new Error('Failed to install FFmpeg via Homebrew. Please install it manually.'); } } else { // For Linux, we'll use apt-get try { await execAsync('sudo apt-get update && sudo apt-get install -y ffmpeg'); ffmpegDir = '/usr/bin'; } catch (error) { console.error('Error installing FFmpeg via apt-get:', error); throw new Error('Failed to install FFmpeg via apt-get. Please install it manually.'); } } // Add FFmpeg directory to PATH const userProfile = process.platform === 'win32' ? process.env.USERPROFILE : process.env.HOME; const pathFile = join(userProfile, '.ffmpeg-path'); await fs.promises.writeFile(pathFile, ffmpegDir); // Clean up if (process.platform === 'win32') { await fs.promises.rm(tempDir, { recursive: true, force: true }); } console.log('FFmpeg installation completed successfully'); return true; } catch (error) { console.error('Error installing FFmpeg:', error); throw error; } } // Get FFmpeg download URL based on platform function getFFmpegDownloadUrl() { return process.platform === 'win32' ? 'https://www.gyan.dev/ffmpeg/builds/' : process.platform === 'darwin' ? 'https://evermeet.cx/ffmpeg/' : 'https://ffmpeg.org/download.html'; } // Initialize default settings if they don't exist async function initializeDefaultSettings() { const defaultSaveDir = join(app.getPath('music'), 'PodcastUpptokur'); console.log('Initializing default save directory:', defaultSaveDir); // Ensure the base directory exists try { await access(defaultSaveDir, constants.F_OK); } catch { console.log('Creating default directory:', defaultSaveDir); await mkdir(defaultSaveDir, { recursive: true }); } // Initialize settings with default values const defaultSettings = { saveDirectory: defaultSaveDir, borgEnvironment: 'borg.unak.is', audioSettings: { bitrate: 256000, sampleRate: 48000, channels: 2 }, userSettings: {} // Store user-specific settings }; // Get current settings or use defaults const currentSettings = store.get('settings') || defaultSettings; // Merge current settings with defaults, preserving user-specific settings const mergedSettings = { ...defaultSettings, ...currentSettings, userSettings: { ...defaultSettings.userSettings, ...(currentSettings.userSettings || {}) } }; // Save the merged settings store.set('settings', mergedSettings); console.log('Settings initialized:', mergedSettings); } // Get FFmpeg path async function getFFmpegPath() { try { // Look in extraResources path in packaged app, otherwise use relative dev path const basePath = app.isPackaged ? process.resourcesPath : join(__dirname, '..'); // Determine the correct subdirectory and binary name based on platform const platformBinDir = process.platform === 'win32' ? 'bin' : 'linux_bin'; const ffmpegBinaryName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'; const ffprobeBinaryName = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'; // Also need ffprobe path const ffmpegPath = join(basePath, 'ffmpeg', platformBinDir, ffmpegBinaryName); // Check access again to be sure try { await fs.promises.access(ffmpegPath); console.log('[Main] Found FFmpeg path for execution:', ffmpegPath); // Return object with both paths, or just ffmpeg path? // Let's return just ffmpeg path for now, processAudioFile needs update return ffmpegPath; } catch (accessError) { console.error('[Main] FFmpeg not accessible at expected path:', ffmpegPath, accessError); return null; } } catch (error) { console.error('[Main] Error getting FFmpeg path:', error); return null; } } // Process audio file with ffmpeg to ensure proper metadata async function processAudioFile(inputPath, outputPath, email) { try { // Check if FFmpeg is installed (this implicitly gets the path now) const ffmpegPath = await getFFmpegPath(); if (!ffmpegPath) { throw new Error('FFmpeg executable not found or not accessible.'); } // Determine platform-specific paths for ffprobe const platformBinDir = process.platform === 'win32' ? 'bin' : 'linux_bin'; const ffprobeBinaryName = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'; // Construct ffprobe path relative to ffmpeg path or base path const ffprobePath = join(path.dirname(ffmpegPath), ffprobeBinaryName); // Verify ffprobe exists too try { await fs.promises.access(ffprobePath); console.log('[Main] Found FFprobe path for execution:', ffprobePath); } catch (ffprobeAccessError) { console.error('[Main] FFprobe not accessible at expected path:', ffprobePath, ffprobeAccessError); throw new Error('FFprobe executable not found or not accessible.'); } // Get current date in ISO format const currentDate = new Date().toISOString(); // Create FFmpeg command with explicit metadata const ffmpegCommand = [ '-i', inputPath, '-c:a', 'libopus', // Use Opus codec for WebM '-b:a', '256k', // Set bitrate to 256k '-ar', '48000', // Set sample rate to 48kHz '-ac', '2', // Set to stereo '-metadata', 'title=Podcast Recording', '-metadata', `artist=${email}`, '-metadata', 'album=PodcastUpptokur', '-metadata', `date=${currentDate}`, '-metadata', 'encoder=PodcastUpptokur', '-metadata', 'copyright=© 2025 PodcastUpptokur', '-metadata', 'genre=Podcast', '-metadata', 'language=is', // Icelandic language code '-y', // Overwrite output file if it exists outputPath ]; console.log('Running FFmpeg command:', ffmpegCommand.join(' ')); // Run FFmpeg command using the found path await new Promise((resolve, reject) => { const ffmpeg = spawn(ffmpegPath, ffmpegCommand); let errorOutput = ''; ffmpeg.stderr.on('data', (data) => { errorOutput += data.toString(); console.log('FFmpeg progress:', data.toString()); }); ffmpeg.on('close', (code) => { if (code === 0) { console.log('FFmpeg processing completed successfully'); resolve(); } else { console.error('FFmpeg error output:', errorOutput); reject(new Error(`FFmpeg process exited with code ${code}`)); } }); ffmpeg.on('error', (err) => { console.error('FFmpeg process error:', err); reject(err); }); }); // Verify the output file exists and has content const stats = await fs.promises.stat(outputPath); if (stats.size === 0) { throw new Error('FFmpeg produced an empty file'); } // Verify metadata using FFprobe using the found path const ffprobeCommand = [ '-v', 'error', '-show_entries', 'format_tags', '-of', 'json', outputPath ]; const metadata = await new Promise((resolve, reject) => { const ffprobe = spawn(ffprobePath, ffprobeCommand); let output = ''; ffprobe.stdout.on('data', (data) => { output += data.toString(); }); ffprobe.on('close', (code) => { if (code === 0) { try { const metadata = JSON.parse(output); resolve(metadata); } catch (err) { reject(new Error('Failed to parse FFprobe output')); } } else { reject(new Error(`FFprobe process exited with code ${code}`)); } }); }); console.log('File metadata:', metadata); // Verify essential metadata fields const tags = metadata.format?.tags || {}; const tagsLower = {}; for (const [key, value] of Object.entries(tags)) { tagsLower[key.toLowerCase()] = value; } const requiredFields = ['title', 'artist', 'date']; const missingFields = requiredFields.filter(field => !tagsLower[field]); if (missingFields.length > 0) { throw new Error(`Missing required metadata fields: ${missingFields.join(', ')}`); } return outputPath; } catch (error) { console.error('Error in processAudioFile:', error); throw error; } } let mainWindow; async function createWindow() { mainWindow = new BrowserWindow({ width: 1200, height: 800, frame: false, webPreferences: { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false } }); // In development, use Vite's dev server if (!app.isPackaged) { try { await mainWindow.loadURL('http://127.0.0.1:5173'); mainWindow.webContents.openDevTools(); } catch (error) { console.error('Failed to load dev server:', error); process.exit(1); } } else { // In production, load the built files mainWindow.loadFile(join(__dirname, '../dist/index.html')); } // Add CSS to make the top bar draggable mainWindow.webContents.insertCSS(` .window-drag { -webkit-app-region: drag; } .window-no-drag { -webkit-app-region: no-drag; } `); mainWindow.on('closed', () => { mainWindow = null; }); } app.whenReady().then(async () => { // Initialize default settings await initializeDefaultSettings(); // Ensure the default directory exists const settings = store.get('settings'); console.log('Current settings:', settings); try { await access(settings.saveDirectory, constants.F_OK); console.log('Directory exists:', settings.saveDirectory); } catch { console.log('Creating directory:', settings.saveDirectory); await mkdir(settings.saveDirectory, { recursive: true }); } await createWindow(); // Check for ffmpeg after window is created const hasFFmpeg = await checkFFmpeg(); // Notify renderer about ffmpeg status mainWindow.webContents.send('ffmpeg-status', hasFFmpeg); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); // IPC handlers ipcMain.handle('get-default-directory', async () => { const settings = store.get('settings'); console.log('Getting default directory:', settings.saveDirectory); // Ensure the directory exists try { await access(settings.saveDirectory, constants.F_OK); } catch { console.log('Creating default directory:', settings.saveDirectory); await mkdir(settings.saveDirectory, { recursive: true }); } return settings.saveDirectory; }); ipcMain.handle('select-directory', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'] }); if (!result.canceled && result.filePaths.length > 0) { const newDir = result.filePaths[0]; console.log('Selected new directory:', newDir); store.set('settings.saveDirectory', newDir); return newDir; } throw new Error('No directory selected'); }); ipcMain.handle('save-recording', async (event, fileName, data, email) => { try { const settings = store.get('settings'); if (!settings || !settings.saveDirectory) { throw new Error('No save directory configured'); } // Defensive check for email if (!email || typeof email !== 'string' || email.trim() === '') { console.error('No email provided to save-recording!'); throw new Error('Email is required to save a recording.'); } // Create email-specific subfolder const userDir = join(settings.saveDirectory, email); console.log('User directory:', userDir); // Ensure the user directory exists try { await access(userDir, constants.F_OK); console.log('User directory exists:', userDir); } catch { console.log('Creating user directory:', userDir); await mkdir(userDir, { recursive: true }); } // Use the user directory for both temp and final files const tempFilePath = join(userDir, `temp_${fileName}`); const finalFilePath = join(userDir, fileName); console.log('Saving recording to:', tempFilePath); // Validate base64 data if (!data || typeof data !== 'string') { throw new Error('Invalid recording data'); } // Write to temporary file first const buffer = Buffer.from(data, 'base64'); await writeFile(tempFilePath, buffer); console.log('Temporary file written successfully'); // Process the audio file with FFmpeg try { await processAudioFile(tempFilePath, finalFilePath, email); console.log('Audio processing completed'); } catch (error) { console.error('Error processing audio:', error); throw new Error('Failed to process audio file'); } // Clean up temporary file try { await unlink(tempFilePath); console.log('Temporary file cleaned up'); } catch (error) { console.warn('Could not delete temporary file:', error); } return finalFilePath; } catch (error) { console.error('Error saving recording:', error); throw error; } }); ipcMain.handle('get-recording-path', async (event, fileName, email) => { try { const settings = store.get('settings'); if (!settings || !settings.saveDirectory) { throw new Error('No save directory configured'); } // Use email-specific subfolder const userDir = join(settings.saveDirectory, email); const filePath = join(userDir, fileName); console.log('Getting recording path:', filePath); return filePath; } catch (error) { console.error('Error getting recording path:', error); throw error; } }); ipcMain.handle('read-recording-data', async (event, fileName, email) => { try { const settings = store.get('settings'); const userDir = path.join(settings.saveDirectory, email); const filePath = path.join(userDir, fileName); console.log('Reading file:', filePath); // Read file using fs.promises const buffer = await fs.promises.readFile(filePath); console.log('File read successfully, size:', buffer.length); if (buffer.length === 0) { throw new Error('File is empty'); } // Convert buffer to base64 const base64 = buffer.toString('base64'); console.log('Base64 data length:', base64.length); // Create data URL with proper MIME type const dataUrl = `data:audio/webm;codecs=opus;base64,${base64}`; console.log('Data URL length:', dataUrl.length); return dataUrl; } catch (error) { console.error('Error reading recording:', error); throw error; } }); ipcMain.handle('delete-recording', async (event, fileName, email) => { try { const settings = store.get('settings'); const userDir = join(settings.saveDirectory, email); const filePath = join(userDir, fileName); console.log('Deleting recording:', filePath); try { await unlink(filePath); console.log('Recording deleted successfully'); } catch (error) { // If the file doesn't exist, we can consider this a successful deletion if (error.code === 'ENOENT') { console.log('File already deleted:', filePath); return; } throw error; } } catch (error) { console.error('Error deleting recording:', error); throw error; } }); ipcMain.handle('list-recordings', async (event, email) => { try { const settings = store.get('settings'); if (!settings || !settings.saveDirectory) { throw new Error('No save directory configured'); } const userDir = join(settings.saveDirectory, email); console.log('Listing recordings in:', userDir); // Ensure directory exists try { await access(userDir, constants.F_OK); console.log('User directory exists:', userDir); } catch { console.log('User directory does not exist, creating:', userDir); await mkdir(userDir, { recursive: true }); console.log('Created user directory, returning empty list'); return []; } const files = await fs.promises.readdir(userDir); console.log('Found files:', files); const recordings = await Promise.all( files .filter(file => file.endsWith('.webm')) .map(async file => { const filePath = join(userDir, file); const stats = await fs.promises.stat(filePath); return { fileName: file, filePath: filePath, date: stats.mtime, size: stats.size }; }) ); // Sort by date, newest first recordings.sort((a, b) => b.date - a.date); console.log('Returning recordings:', recordings); return recordings; } catch (error) { console.error('Error listing recordings:', error); throw error; } }); ipcMain.handle('upload-recording', async (event, fileName, email, borgEnvironment) => { try { const settings = store.get('settings'); const userDir = join(settings.saveDirectory, email); const filePath = join(userDir, fileName); console.log('Uploading file:', filePath); // Get file stats const stats = await fs.promises.stat(filePath); console.log('File stats:', { size: stats.size, created: stats.birthtime, modified: stats.mtime }); // Use the final file directly, do not process again // Create form data using form-data package const form = new FormData(); // Append final file using createReadStream with proper content type form.append('audioFile', fs.createReadStream(filePath), { filename: fileName, contentType: 'audio/webm' // Always use audio/webm for both regular and saved files }); form.append('userEmail', email); // Create request options const options = { hostname: borgEnvironment || 'borg.unak.is', path: '/api/studio/podcasts', method: 'POST', headers: { 'X-API-Key': '0f4801033b3622cc2029e5f2159ff76ea0e3e326', 'Content-Type': 'multipart/form-data', ...form.getHeaders() } }; console.log('Request options:', { hostname: options.hostname, path: options.path, method: options.method, headers: options.headers }); return new Promise((resolve, reject) => { const req = https.request(options, (res) => { let data = ''; console.log('Response headers:', res.headers); res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { console.log('Server response status:', res.statusCode); console.log('Server response:', data); if (res.statusCode >= 200 && res.statusCode < 300) { console.log('Upload completed successfully'); resolve(); } else { console.error('Upload failed with status:', res.statusCode); console.error('Response:', data); reject(new Error(`Upload failed with status ${res.statusCode}: ${data}`)); } }); }); req.on('error', (error) => { console.error('Upload error:', error); reject(error); }); // Handle upload progress form.on('progress', (bytesUploaded, bytesTotal) => { if (bytesTotal) { const progress = (bytesUploaded / bytesTotal) * 100; console.log('Upload progress:', progress.toFixed(2) + '%'); event.sender.send('upload-progress', progress); } }); // Pipe the form data to the request form.pipe(req); }); } catch (error) { console.error('Error in upload-recording handler:', error); throw error; } }); // Add new IPC handlers for ffmpeg status ipcMain.handle('check-ffmpeg', async () => { const hasFFmpeg = await checkFFmpeg(); return hasFFmpeg; }); // Add new IPC handler for FFmpeg download URL ipcMain.handle('get-ffmpeg-download-url', () => { return getFFmpegDownloadUrl(); }); // Add new IPC handler for FFmpeg installation ipcMain.handle('install-ffmpeg', async () => { try { await installFFmpeg(); // Check if installation was successful const hasFFmpeg = await checkFFmpeg(); if (hasFFmpeg) { mainWindow.webContents.send('ffmpeg-status', true); return true; } return false; } catch (error) { console.error('Error in install-ffmpeg handler:', error); throw error; } }); // Add new IPC handler for app restart ipcMain.handle('restart-app', () => { app.relaunch(); app.exit(0); }); // Add new IPC handler for getting user settings ipcMain.handle('get-user-settings', async (event, email) => { try { console.log(`[Main] IPC: Handling get-user-settings for ${email}`); const settings = store.get('settings'); // Ensure settings and userSettings exist before access const userSpecificSettings = (settings && settings.userSettings && settings.userSettings[email]) ? settings.userSettings[email] : {}; console.log(`[Main] IPC: Returning user settings for ${email}:`, userSpecificSettings); return userSpecificSettings; } catch (error) { console.error(`[Main] IPC: Error in get-user-settings for ${email}:`, error); throw error; } }); // Add new IPC handler for saving user settings ipcMain.handle('save-user-settings', async (event, email, userSettings) => { try { console.log(`[Main] IPC: Handling save-user-settings for ${email}:`, userSettings); const settings = store.get('settings') || {}; // Ensure settings is at least an empty object // Ensure userSettings container exists if (!settings.userSettings) { settings.userSettings = {}; } settings.userSettings[email] = { ...(settings.userSettings[email] || {}), // Ensure existing settings for the user are merged correctly ...userSettings }; store.set('settings', settings); console.log(`[Main] IPC: Saved user settings for ${email}:`, settings.userSettings[email]); return true; } catch (error) { console.error(`[Main] IPC: Error saving user settings for ${email}:`, error); throw error; } }); // Handle renaming recordings ipcMain.handle('rename-recording', async (event, oldFileName, newFileName, email) => { try { console.log('Renaming recording:', { oldFileName, newFileName, email }); // Get the save directory from settings const settings = store.get('settings'); if (!settings || !settings.saveDirectory) { throw new Error('No save directory configured'); } // Ensure the new filename has .webm extension if (!newFileName.endsWith('.webm')) { newFileName = `${newFileName}.webm`; } // Construct full paths const oldPath = join(settings.saveDirectory, email, oldFileName); const newPath = join(settings.saveDirectory, email, newFileName); // Check if old file exists try { await access(oldPath, constants.F_OK); } catch { throw new Error('Original file not found'); } // Check if new file already exists try { await access(newPath, constants.F_OK); throw new Error('A file with this name already exists'); } catch { // File doesn't exist, which is what we want } // Perform the rename await fs.promises.rename(oldPath, newPath); console.log('File renamed successfully'); return true; } catch (error) { console.error('Error renaming recording:', error); throw error; } }); // Add new IPC handlers for logging ipcMain.handle('saveLog', (event, logEntry) => { saveLog(logEntry); }); ipcMain.handle('getLogs', () => { return getLogs(); }); ipcMain.handle('clearLogs', () => { clearLogs(); }); // Add IPC handler for app version ipcMain.handle('get-app-version', () => { return app.getVersion(); });