900 lines
28 KiB
JavaScript

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();
});