- Enable contextIsolation, disable nodeIntegration, remove enableRemoteModule - Add preload.js with safe contextBridge API exposure - Fix OS command injection: exec() → execFile() in open-file-in-player - Fix DOM XSS: replace innerHTML with textContent/createElement in FileListManager, UIManager, ModalManager - Remove direct fs/electron access from renderer, route through IPC - Add validate-folder and get-file-stats IPC handlers - Load renderer modules via script tags with require polyfill
941 lines
31 KiB
JavaScript
941 lines
31 KiB
JavaScript
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();
|
|
}
|
|
});
|