const { app, BrowserWindow, dialog, ipcMain } = require('electron'); const path = require('path'); const { scanDirectory, extractFileMetadata } = require('./utils/fileUtils'); const dotenv = require('dotenv'); // Load environment variables from .env file dotenv.config(); // Check if a directory was passed as command line argument const commandLineDirectory = process.argv.find(arg => arg.startsWith('--dir=') || arg.startsWith('-d='))?.split('=')[1]; // Debug: Log command line arguments console.log('Command line arguments:', process.argv); console.log('Parsed directory:', commandLineDirectory); // Handle creating/removing shortcuts on Windows when installing/uninstalling. try { if (require('electron-squirrel-startup')) { app.quit(); } } catch (error) { // electron-squirrel-startup not available on non-Windows platforms console.log('electron-squirrel-startup not available (expected on non-Windows platforms)'); } let mainWindow; // Create the browser window. function createWindow() { mainWindow = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, sandbox: false, }, }); // and load the index.html of the app. mainWindow.loadFile('index.html'); // If a directory was provided in command line, automatically select it if (commandLineDirectory) { console.log('Auto-selecting directory:', commandLineDirectory); // Send a message to the renderer to automatically select the directory setTimeout(() => { mainWindow.webContents.send('auto-select-directory', commandLineDirectory); }, 1000); } } // IPC handler for selecting directory ipcMain.handle('select-directory', async () => { try { const result = await dialog.showOpenDialog({ properties: ['openDirectory'] }); if (result.canceled) { return { success: false, error: 'No directory selected' }; } return { success: true, directory: result.filePaths[0] }; } catch (error) { return { success: false, error: error.message }; } }); // IPC handler for scanning directory ipcMain.handle('scan-directory', async (event, directoryPath) => { try { if (!directoryPath) { return { success: false, error: 'No directory path provided' }; } // Progress callback to send updates to renderer const progressCallback = (current, total, fileName) => { if (mainWindow && mainWindow.webContents) { mainWindow.webContents.send('scan-progress', { current, total, fileName }); } }; const files = await scanDirectory(directoryPath, progressCallback); return { success: true, files: files }; } catch (error) { return { success: false, error: error.message }; } }); // Enhanced logging for debugging problematic files ipcMain.handle('log-file-info', async (event, filePath) => { try { const fs = require('fs'); const stats = fs.statSync(filePath); return { success: true, fileInfo: { path: filePath, size: stats.size, modified: stats.mtime, isFile: stats.isFile(), isDirectory: stats.isDirectory() } }; } catch (error) { return { success: false, error: error.message }; } }); // IPC handler for renaming file ipcMain.handle('rename-file', async (event, { oldPath, newName }) => { try { const fs = require('fs'); const newPath = path.join(path.dirname(oldPath), newName); // Check if file exists if (!fs.existsSync(oldPath)) { return { success: false, error: 'Original file does not exist' }; } // Check if file is writable try { fs.accessSync(oldPath, fs.constants.W_OK); } catch (err) { return { success: false, error: 'File is not writable' }; } // Rename the file fs.renameSync(oldPath, newPath); // Write audit log const fileDir = path.dirname(oldPath); writeAuditLog(fileDir, 'rename_file', { oldPath: oldPath, newPath: newPath, oldName: path.basename(oldPath), newName: newName }); return { success: true, message: 'File renamed successfully' }; } catch (error) { return { success: false, error: error.message }; } }); // Create log file for debugging const fs = require('fs'); // Ensure log file exists at startup const logFilePath = path.join(__dirname, 'app-debug.log'); try { // Try to create or truncate the log file fs.writeFileSync(logFilePath, `=== Application started at ${new Date().toISOString()} ===\n`); console.log('Debug log file created successfully at:', logFilePath); } catch (error) { console.error('Failed to create debug log file:', error.message); throw new Error('Cannot create debug log file - check permissions'); } function writeLog(message) { const timestamp = new Date().toISOString(); const logEntry = `[${timestamp}] ${message}\n`; try { fs.appendFileSync(logFilePath, logEntry); console.log(logEntry.trim()); } catch (error) { console.error('Failed to write to log file:', error.message); } } // Function to write audit log to directory-specific audit file function writeAuditLog(directoryPath, action, details) { const auditFileName = '.audit'; const auditFilePath = path.join(directoryPath, auditFileName); const timestamp = new Date().toISOString(); const auditEntry = { timestamp: timestamp, action: action, details: details }; try { const entryString = JSON.stringify(auditEntry) + '\n'; fs.appendFileSync(auditFilePath, entryString); writeLog(`Audit entry written: ${action} - ${JSON.stringify(details)}`); } catch (error) { writeLog(`Failed to write audit entry: ${error.message}`); console.error('Failed to write audit entry:', error.message); } } // IPC handler for moving file to folder ipcMain.handle('move-file-to-folder', async (event, { filePath, folderName }) => { try { writeLog(`Move file request received: ${filePath} to ${folderName}`); // Note: fs and path are already required at the top level // Check if file exists if (!fs.existsSync(filePath)) { const error = 'File does not exist'; writeLog(`ERROR: ${error}`); return { success: false, error: error }; } // Validate folder name and map to actual folder names // "extra" maps to "extras" for Jellyfin compatibility // "behind-the-scenes" maps to "behind the scenes" const validFolders = ['extra', 'behind-the-scenes', 'commentary', 'delete']; if (!validFolders.includes(folderName)) { const error = 'Invalid folder name. Must be "extra", "behind-the-scenes", "commentary", or "delete"'; writeLog(`ERROR: ${error}`); return { success: false, error: error }; } // Map folder names to actual directory names let actualFolderName; if (folderName === 'extra') { actualFolderName = 'extras'; } else if (folderName === 'behind-the-scenes') { actualFolderName = 'behind the scenes'; } else { actualFolderName = folderName; // 'commentary' and 'delete' stay as-is } // Get directory containing the file const fileDir = path.dirname(filePath); const targetFolder = path.join(fileDir, actualFolderName); writeLog(`Moving file from: ${filePath}`); writeLog(`Target folder: ${targetFolder}`); // Create the target folder if it doesn't exist if (!fs.existsSync(targetFolder)) { writeLog(`Creating target folder: ${targetFolder}`); fs.mkdirSync(targetFolder, { recursive: true }); } // Generate new file path const fileName = path.basename(filePath); const newFilePath = path.join(targetFolder, fileName); writeLog(`New file path: ${newFilePath}`); // Check if file with same name already exists in target folder if (fs.existsSync(newFilePath)) { const error = 'File with same name already exists in target folder'; writeLog(`ERROR: ${error}`); return { success: false, error: error }; } // Move the file writeLog('Moving file...'); fs.renameSync(filePath, newFilePath); writeLog(`File moved successfully to: ${newFilePath}`); // Write audit log writeAuditLog(fileDir, 'move_file', { originalPath: filePath, newPath: newFilePath, folder: folderName }); return { success: true, message: `File moved to ${folderName} folder successfully` }; } catch (error) { writeLog(`CRITICAL ERROR in move-file-to-folder: ${error.message}`); writeLog(`Stack trace: ${error.stack}`); return { success: false, error: error.message }; } }); // IPC handler for beginning the mapping process ipcMain.handle('begin-mapping', async (event, { directory, files, tvdbId }) => { writeLog(`[BEGIN-MAPPING] Starting mapping for ${files.length} files in ${directory}`); try { const fs = require('fs'); const path = require('path'); // Get season folder name (direct parent of files) const seasonFolder = path.basename(directory); // Get show folder (parent of season folder) const showFolderPath = path.dirname(directory); const showFolderName = path.basename(showFolderPath); // Extract season number from folder name (e.g., "Season 01" -> 1, "S01" -> 1) const seasonMatch = seasonFolder.match(/(?:season\s*|s)(\d+)/i); const seasonNumber = seasonMatch ? parseInt(seasonMatch[1]) : 1; writeLog(`[BEGIN-MAPPING] Show folder: ${showFolderName}, Season: ${seasonNumber}`); // Extract clean show name (without tvdbid bracket if present) // Matches [tvdbid-XXXXX] or [tvdbid-series-XXXXX] formats // Also removes leading semicolons or other problematic characters let showName = showFolderName.replace(/\s*\[tvdbid-[^\]]+\]/, '').trim(); showName = showName.replace(/^[;:]+/, '').trim(); // Rename each file first (using original paths) let successCount = 0; let errorCount = 0; for (const file of files) { try { const oldPath = file.filePath; const ext = path.extname(oldPath); const seasonNum = String(seasonNumber).padStart(2, '0'); // Handle episode ranges (e.g., "1-3" for multiple episodes) const episodeStart = file.episodeStart || file.episodeNumber || 1; const episodeEnd = file.episodeEnd !== undefined ? file.episodeEnd : episodeStart; // If range is valid (start < end), create multi-episode files if (episodeEnd > episodeStart) { // Multi-episode range (e.g., "ShowName S01E01-E03 - 1080p.ext") const startEpNum = String(episodeStart).padStart(2, '0'); const endEpNum = String(episodeEnd).padStart(2, '0'); let newFileName = `${showName} S${seasonNum}E${startEpNum}-E${endEpNum}`; if (file.quality && file.quality !== 'N/A' && file.quality !== '-') { newFileName += ` - ${file.quality}`; } newFileName += ext; const newPath = path.join(path.dirname(oldPath), newFileName); writeLog(`[BEGIN-MAPPING] Processing range: ${episodeStart}-${episodeEnd}, new filename: ${newFileName}`); // Check if target already exists if (fs.existsSync(newPath)) { writeLog(`[BEGIN-MAPPING] Target exists, skipping: ${newFileName}`); errorCount++; continue; } fs.renameSync(oldPath, newPath); writeLog(`[BEGIN-MAPPING] Renamed range: ${path.basename(oldPath)} -> ${newFileName}`); successCount++; } else { // Single episode (original logic) const epNum = String(episodeStart).padStart(2, '0'); let newFileName = `${showName} S${seasonNum}E${epNum}`; if (file.quality && file.quality !== 'N/A' && file.quality !== '-') { newFileName += ` - ${file.quality}`; } newFileName += ext; const newPath = path.join(path.dirname(oldPath), newFileName); // Skip if file already has the correct name if (oldPath === newPath) { writeLog(`[BEGIN-MAPPING] File already mapped: ${path.basename(oldPath)}`); successCount++; continue; } // Check if target already exists if (fs.existsSync(newPath)) { writeLog(`[BEGIN-MAPPING] Target exists, skipping: ${newFileName}`); errorCount++; continue; } fs.renameSync(oldPath, newPath); writeLog(`[BEGIN-MAPPING] Renamed: ${path.basename(oldPath)} -> ${newFileName}`); successCount++; } } catch (fileErr) { writeLog(`[BEGIN-MAPPING] Error renaming file: ${fileErr.message}`); errorCount++; } } // Now rename show folder at the end (if needed) // Determine if we're in a season folder (has season pattern) or show folder const isSeasonFolder = seasonFolder.match(/(?:season\s*|s)(\d+)/i); const folderToRename = isSeasonFolder ? showFolderPath : directory; const folderName = isSeasonFolder ? showFolderName : seasonFolder; let newDirectory = directory; if (tvdbId && !folderName.includes('[tvdbid-')) { const newFolderName = `${folderName} [tvdbid-${tvdbId}]`; const newFolderPath = path.join(path.dirname(folderToRename), newFolderName); try { fs.renameSync(folderToRename, newFolderPath); writeLog(`[BEGIN-MAPPING] Renamed folder to: ${newFolderName}`); newDirectory = isSeasonFolder ? path.join(newFolderPath, seasonFolder) : newFolderPath; writeLog(`[BEGIN-MAPPING] New directory path: ${newDirectory}`); } catch (renameErr) { writeLog(`[BEGIN-MAPPING] Could not rename folder: ${renameErr.message}`); } } writeLog(`[BEGIN-MAPPING] Complete. Success: ${successCount}, Errors: ${errorCount}`); return { success: true, renamed: successCount, errors: errorCount, newDirectory: newDirectory }; } catch (error) { writeLog(`[BEGIN-MAPPING] Critical error: ${error.message}`); return { success: false, error: error.message }; } }); // Function to test moving files (for debugging purposes) function testMoveFunction() { // This is for testing the function directly from main process console.log('Testing move function directly...'); const fs = require('fs'); const path = require('path'); const testDir = path.join(__dirname, 'test_debug'); const testFilePath = path.join(testDir, 'test_video.mp4'); // Clean up test dir if (fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true }); } // Create test dir and file fs.mkdirSync(testDir, { recursive: true }); fs.writeFileSync(testFilePath, 'test content'); console.log('Created test file:', testFilePath); // Test moving to extra const targetFolder = path.join(testDir, 'extra'); fs.mkdirSync(targetFolder, { recursive: true }); const newFilePath = path.join(targetFolder, 'test_video.mp4'); fs.renameSync(testFilePath, newFilePath); console.log('Test move completed successfully'); console.log('File now at:', newFilePath); } // IPC handler for searching TVDB ipcMain.handle('search-tvdb', async (event, query) => { try { // Import required modules const axios = require('axios'); // Get API key from environment const apiKey = process.env.TVDB_API_KEY || process.env.API_KEY; if (!apiKey) { throw new Error('TVDB_API_KEY not found in environment variables'); } // For TVDB v4 API, we need to first get an authentication token // The token is valid for 1 month let token = null; // Try to get token from cache or login try { const loginResponse = await axios.post('https://api4.thetvdb.com/v4/login', { apikey: apiKey }, { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); token = loginResponse.data.data.token; } catch (loginError) { console.error('TVDB Login Error:', loginError.message); throw new Error('Failed to authenticate with TVDB API: ' + loginError.message); } // Make API request to TheTVDB v4 search endpoint const response = await axios.get('https://api4.thetvdb.com/v4/search', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json' }, params: { query: query } }); // Process the results - v4 API returns different structure const results = response.data.data.map(show => ({ id: show.id, seriesName: show.name, status: show.status?.name, firstAired: show.firstAired, overview: show.overview, image: show.image, slug: show.slug })); return { success: true, results: results }; } catch (error) { console.error('TVDB API Error:', error.message); console.error('Error status:', error.response?.status); console.error('Error data:', error.response?.data); // No more mock data - propagate the actual error return { success: false, error: 'Failed to fetch data from TVDB API: ' + error.message }; } }); // IPC handler for getting show details including seasons ipcMain.handle('get-show-details', async (event, showId) => { try { // Import required modules const axios = require('axios'); // Get API key from environment const apiKey = process.env.TVDB_API_KEY || process.env.API_KEY; if (!apiKey) { throw new Error('TVDB_API_KEY not found in environment variables'); } // For TVDB v4 API, we need to first get an authentication token let token = null; // Try to get token from cache or login try { const loginResponse = await axios.post('https://api4.thetvdb.com/v4/login', { apikey: apiKey }, { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); token = loginResponse.data.data.token; } catch (loginError) { console.error('TVDB Login Error:', loginError.message); throw new Error('Failed to authenticate with TVDB API: ' + loginError.message); } // Extract numeric ID from the show ID (handle both formats: series-73011 and 73011) let numericId = showId; if (showId && showId.startsWith('series-')) { numericId = showId.split('series-')[1]; } // Get extended show details which includes season information const response = await axios.get(`https://api4.thetvdb.com/v4/series/${numericId}/extended`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json' } }); if (response.data && response.data.data) { const showData = response.data.data; // Fetch episodes to count per season let episodeCounts = {}; try { const episodesResponse = await axios.get(`https://api4.thetvdb.com/v4/series/${numericId}/episodes/default`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json' }, params: { page: 0 } }); if (episodesResponse.data?.data?.episodes) { episodesResponse.data.data.episodes.forEach(ep => { const sn = ep.seasonNumber; episodeCounts[sn] = (episodeCounts[sn] || 0) + 1; }); } } catch (epError) { console.log('Could not fetch episodes for count:', epError.message); } // Extract season information properly let seasons = []; if (showData.seasons && Array.isArray(showData.seasons)) { seasons = showData.seasons.map(season => ({ id: season.id, number: season.number, type: season.type?.name || 'Unknown', episodeCount: episodeCounts[season.number] || 0 })); } return { success: true, data: { id: showData.id, name: showData.name, status: showData.status?.name || showData.status, firstAired: showData.firstAired, overview: showData.overview, image: showData.image, slug: showData.slug, seasons: seasons } }; } else { throw new Error('Invalid response structure from TVDB API'); } } catch (error) { console.error('TVDB API Error getting show details:', error.message); console.error('Error status:', error.response?.status); console.error('Error data:', error.response?.data); // Return the error without trying fallbacks that cause scope issues return { success: false, error: 'Failed to fetch show details from TVDB API: ' + error.message }; } }); // IPC handler for getting season episodes ipcMain.handle('get-season-episodes', async (event, showId, seasonNumber) => { try { // Import required modules const axios = require('axios'); // Get API key from environment const apiKey = process.env.TVDB_API_KEY || process.env.API_KEY; if (!apiKey) { throw new Error('TVDB_API_KEY not found in environment variables'); } // For TVDB v4 API, we need to first get an authentication token let token = null; // Try to get token from cache or login try { const loginResponse = await axios.post('https://api4.thetvdb.com/v4/login', { apikey: apiKey }, { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); token = loginResponse.data.data.token; } catch (loginError) { console.error('TVDB Login Error:', loginError.message); throw new Error('Failed to authenticate with TVDB API: ' + loginError.message); } // Extract numeric ID from the show ID (handle both formats: series-73011 and 73011) let numericId = showId; if (showId && showId.startsWith('series-')) { numericId = showId.split('series-')[1]; } // Try to get episodes for a specific season using the correct endpoint structure // Based on the API documentation, we should use: /series/{id}/episodes/{season-type} // where season-type can be "default", "aired", "dvd", etc. try { // First try the season-specific endpoint const episodesResponse = await axios.get(`https://api4.thetvdb.com/v4/series/${numericId}/episodes/default`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json' }, params: { page: 0 } }); if (episodesResponse.data && episodesResponse.data.data) { // Process episodes and filter by season number const episodes = episodesResponse.data.data.episodes ? episodesResponse.data.data.episodes .filter(episode => episode.seasonNumber === seasonNumber) .map(episode => ({ id: episode.id, name: episode.name, number: episode.number, seasonNumber: episode.seasonNumber, aired: episode.aired, overview: episode.overview, runtime: episode.runtime })) : []; return { success: true, data: { showId: showId, seasonNumber: seasonNumber, episodes: episodes } }; } } catch (seasonError) { console.log('Season-specific endpoint failed, falling back to all episodes:', seasonError.message); // If season-specific endpoint fails, fall back to getting all episodes and filtering const episodesResponse = await axios.get(`https://api4.thetvdb.com/v4/series/${numericId}/episodes`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json' }, params: { page: 0 } }); if (episodesResponse.data && episodesResponse.data.data) { // Process episodes and filter by season number const episodes = episodesResponse.data.data.episodes ? episodesResponse.data.data.episodes .filter(episode => episode.seasonNumber === seasonNumber) .map(episode => ({ id: episode.id, name: episode.name, number: episode.number, seasonNumber: episode.seasonNumber, aired: episode.aired, overview: episode.overview, runtime: episode.runtime })) : []; return { success: true, data: { showId: showId, seasonNumber: seasonNumber, episodes: episodes } }; } } throw new Error('Failed to fetch episodes from both endpoints'); } catch (error) { console.error('TVDB API Error getting season episodes:', error.message); console.error('Error status:', error.response?.status); console.error('Error data:', error.response?.data); // Return a more specific error message for debugging return { success: false, error: 'Failed to fetch season episodes from TVDB API: ' + error.message, debug: { showId: showId, seasonNumber: seasonNumber, status: error.response?.status, data: error.response?.data } }; } }); // Test function to verify API connectivity ipcMain.handle('test-tvdb-api', async () => { try { // Test actual API connectivity const axios = require('axios'); const apiKey = process.env.TVDB_API_KEY || process.env.API_KEY; if (!apiKey) { return { success: false, error: 'TVDB_API_KEY not found in environment variables' }; } // Test login endpoint const loginResponse = await axios.post('https://api4.thetvdb.com/v4/login', { apikey: apiKey }, { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); if (loginResponse.data && loginResponse.data.data && loginResponse.data.data.token) { return { success: true, message: 'TVDB API test successful - Token obtained', timestamp: new Date().toISOString() }; } else { return { success: false, error: 'TVDB API test failed - No token received' }; } } catch (error) { return { success: false, error: 'TVDB API test failed: ' + error.message }; } }); // IPC handler for moving file to extras folder ipcMain.handle('move-to-extras', async (event, filePath) => { try { const fs = require('fs'); const path = require('path'); // Check if file exists if (!fs.existsSync(filePath)) { return { success: false, error: 'File does not exist' }; } // Get the directory of the file const fileDir = path.dirname(filePath); const extrasDir = path.join(fileDir, 'extras'); // Create extras directory if it doesn't exist if (!fs.existsSync(extrasDir)) { fs.mkdirSync(extrasDir, { recursive: true }); } // Get the filename const fileName = path.basename(filePath); // Construct the destination path const destPath = path.join(extrasDir, fileName); // Move the file fs.renameSync(filePath, destPath); return { success: true, message: 'File moved to extras folder successfully', sourcePath: filePath, destinationPath: destPath }; } catch (error) { console.error('Error moving file to extras:', error); return { success: false, error: error.message }; } }); // IPC handler for opening file in default player // FIXED: Use execFile instead of exec to prevent command injection ipcMain.handle('open-file-in-player', async (event, filePath) => { try { const { execFile } = require('child_process'); // Open file in default player based on OS using execFile (safe against injection) let command, args; if (process.platform === 'darwin') { // macOS command = 'open'; args = [filePath]; } else if (process.platform === 'win32') { // Windows command = 'cmd.exe'; args = ['/c', 'start', '""', filePath]; } else { // Linux command = 'xdg-open'; args = [filePath]; } execFile(command, args, (error, stdout, stderr) => { if (error) { console.error('Error opening file:', error); } else { console.log('File opened successfully:', filePath); } }); return { success: true, message: 'File opened in default player' }; } catch (error) { console.error('Error opening file in player:', error); return { success: false, error: error.message }; } }); // IPC handler for validating folder path (renderer can't access fs directly) ipcMain.handle('validate-folder', async (event, folderPath) => { try { const stats = fs.statSync(folderPath); return { success: true, exists: true, isDirectory: stats.isDirectory() }; } catch (error) { return { success: false, exists: false, isDirectory: false, error: error.message }; } }); // IPC handler for getting file stats (renderer can't access fs directly) ipcMain.handle('get-file-stats', async (event, filePath) => { try { const stats = fs.statSync(filePath); return { success: true, size: stats.size, mtime: stats.mtime, isFile: stats.isFile(), isDirectory: stats.isDirectory() }; } catch (error) { return { success: false, error: error.message }; } }); // IPC handler for logging audit events ipcMain.handle('log-audit-event', async (event, { directoryPath, action, details }) => { try { writeAuditLog(directoryPath, action, details); return { success: true, message: 'Audit event logged successfully' }; } catch (error) { console.error('Error logging audit event:', error); return { success: false, error: error.message }; } }); // This method will be called when Electron has finished // initialization and is ready to create browser windows. app.whenReady().then(createWindow); // Quit when all windows are closed, except on macOS. There, it's common // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } }); app.on('activate', () => { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } });