diff --git a/renderer.js b/renderer.js index 62f5802..fae0ab7 100644 --- a/renderer.js +++ b/renderer.js @@ -1,1608 +1,64 @@ +const UIManager = require('./utils/renderer/UIManager'); + +// Initialize the application when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + console.log('Movie Mapper application initialized'); + + // Create and initialize the UI manager + const uiManager = new UIManager(); + + // Expose to window for debugging and external access + window.uiManager = uiManager; + + // Debug function to log problematic file info + window.debugProblematicFile = async (filePath) => { + await uiManager.debugProblematicFile(filePath); + }; + + // Test TVDB API connectivity + window.testTVDBAPI = async () => { + await uiManager.searchManager.testTVDBAPI(); + }; + + // Open video preview + window.openVideoPreview = async (filePath) => { + await uiManager.modalManager.openVideoPreview(filePath); + }; + + // Open directory + window.openDirectory = async (directory) => { + await uiManager.openDirectory(directory); + }; + + // Make file editable + window.makeEditable = (element) => { + uiManager.makeEditable(element); + }; + + console.log('UIManager initialized and exposed to window'); +}); + +// IPC handlers for file operations (these are called from main process) +// File rename handler const { ipcRenderer } = require('electron'); -const path = require('path'); -// DOM Elements -const selectDirBtn = document.getElementById('select-dir-btn'); -const selectedDirEl = document.getElementById('selected-dir'); -const searchInput = document.getElementById('search-input'); -const searchResultsEl = document.getElementById('search-results'); -const fileListEl = document.getElementById('file-list'); -const showDetailsEl = document.getElementById('show-details'); -const progressContainer = document.getElementById('progress-container'); -const progressText = document.getElementById('progress-text'); -const progressCount = document.getElementById('progress-count'); - -// Current state -let currentDirectory = null; -let currentFiles = []; -let currentShow = null; -let currentSeasons = []; -let currentEpisodes = []; -let selectedSeasonEpisodeCount = 0; -let isUpdatingEpisodeNumbers = false; - -// Event Listeners -selectDirBtn.addEventListener('click', selectDirectory); -searchInput.addEventListener('input', debounce(searchShows, 300)); - -// Add click handler for episode number editing (ranges) -document.addEventListener('click', function(e) { - if (e.target.classList.contains('episode-number')) { - e.stopPropagation(); - makeEpisodeRangeEditable(e.target); - } -}); - - - -// Add click handler for Begin Mapping button -document.getElementById('begin-mapping-btn').addEventListener('click', beginMapping); - -// Add click handler for the floating circle (play button) to move all tagged files -document.getElementById('tagged-circle').addEventListener('click', moveAllTaggedFiles); - -// Listen for scan progress updates from main process -ipcRenderer.on('scan-progress', (event, { current, total, fileName }) => { - updateProgress(current, total, fileName); -}); - -// Listen for auto-select directory message -window.addEventListener('message', (event) => { - if (event.data.type === 'auto-select-directory') { - autoSelectDirectory(event.data.directory); - } -}); - -// Listen for auto-select directory from main process -ipcRenderer.on('auto-select-directory', (event, directory) => { - autoSelectDirectory(directory); -}); - -// Open a specific directory (for folder navigation) -async function openDirectory(directory) { +// Handle file rename requests +ipcRenderer.invoke('rename-file', async (event, { oldPath, newName }) => { + const path = require('path'); try { - console.log('Opening directory:', directory); - - // Set the current directory - currentDirectory = directory; - selectedDirEl.textContent = `Selected: ${directory}`; - - // Log audit event for directory selection - logAuditEvent('select_directory', { directory: directory }); - - // Scan the directory for media files - await scanDirectory(directory); - } catch (error) { - console.error('Error opening directory:', error); - alert(`Error: ${error.message}`); - } -} + const oldDir = path.dirname(oldPath); + const newPath = path.join(oldDir, newName); -// Auto-select directory function -async function autoSelectDirectory(directory) { - try { - console.log('Auto-selecting directory:', directory); - - // Set the current directory - currentDirectory = directory; - selectedDirEl.textContent = `Selected: ${directory}`; - - // Log audit event for directory selection - logAuditEvent('select_directory', { directory: directory }); - - // Scan the directory for media files - await scanDirectory(directory); - } catch (error) { - console.error('Error auto-selecting directory:', error); - alert(`Error: ${error.message}`); - } -} - -// Select directory function -async function selectDirectory() { - try { - // Use IPC to handle directory selection - const result = await ipcRenderer.invoke('select-directory'); - - if (result.success) { - const directory = result.directory; - currentDirectory = directory; - selectedDirEl.textContent = `Selected: ${directory}`; - - // Log audit event for directory selection - logAuditEvent('select_directory', { directory: directory }); - - // Scan the directory for media files - await scanDirectory(directory); - } else { - throw new Error(result.error); - } - } catch (error) { - console.error('Error selecting directory:', error); - alert(`Error: ${error.message}`); - } -} - -// Function to log audit events to the main process -function logAuditEvent(action, details) { - if (currentDirectory) { - ipcRenderer.invoke('log-audit-event', { - directoryPath: currentDirectory, - action: action, - details: details - }).catch(error => { - console.error('Failed to log audit event:', error); - }); - } -} - -// Scan directory for media files -async function scanDirectory(directoryPath) { - try { - // Show progress bar - showProgress(); - - const result = await ipcRenderer.invoke('scan-directory', directoryPath); - - // Hide progress bar - hideProgress(); - - if (result.success) { - currentFiles = result.files; - displayFiles(currentFiles); - } else { - throw new Error(result.error); - } - } catch (error) { - hideProgress(); - console.error('Error scanning directory:', error); - alert(`Error scanning directory: ${error.message}`); - } -} - -// Progress spinner functions -function showProgress() { - if (progressContainer) { - progressContainer.style.display = 'block'; - progressText.textContent = 'Scanning directory...'; - progressCount.textContent = ''; - } -} - -function hideProgress() { - if (progressContainer) { - progressContainer.style.display = 'none'; - } -} - -function updateProgress(current, total, fileName) { - if (progressContainer) { - const displayCurrent = current + 1; - progressText.textContent = `Processing: ${fileName}`; - progressCount.textContent = `${displayCurrent} of ${total} files`; - } -} - -// Display files in the UI -function displayFiles(files) { - fileListEl.innerHTML = ''; - - if (files.length === 0) { - fileListEl.innerHTML = '

No media files found in this directory.

'; - return; - } - - // Separate folders from media files - const folders = files.filter(f => f.isFolder); - const mediaFiles = files.filter(f => !f.isFolder); - - // Track episode number for media files only - let episodeNumber = 1; - - // Display folders first (not draggable, no episode number) - folders.forEach(file => { - const fileItem = document.createElement('div'); - fileItem.className = 'file-item folder-item'; - fileItem.innerHTML = ` -
📁
-
${file.name}
-
-
-
-
- `; - - // Add click handler to navigate into folder - fileItem.addEventListener('click', function() { - openDirectory(file.path); - }); - - fileListEl.appendChild(fileItem); - }); - - // Display media files with episode numbers and drag/drop - mediaFiles.forEach((file, index) => { - const fileItem = document.createElement('div'); - fileItem.className = 'file-item'; - fileItem.draggable = true; - fileItem.dataset.index = index; - fileItem.dataset.filePath = file.path; - - // Use actual duration from file metadata - const duration = file.duration || '00:00'; - const quality = file.quality || 'unknown'; - const fps = file.fps || 'unknown'; - - // Add label for problematic files - const problemLabel = file.isProblematic ? '⚠️ ' : ''; - - fileItem.innerHTML = ` -
⋮⋮
-
- -
${episodeNumber}
- -
-
${file.name}
-
${duration}
-
${quality}
-
${fps}
-
- 🏷️ - 💬 - 🗑️ - 🎬 - -
- `; - - // Add drag and drop event listeners - fileItem.addEventListener('dragstart', handleDragStart); - fileItem.addEventListener('dragend', handleDragEnd); - fileItem.addEventListener('dragover', handleDragOver); - fileItem.addEventListener('dragleave', handleDragLeave); - fileItem.addEventListener('drop', handleDrop); - - // Add click event to make file name editable - const fileNameElement = fileItem.querySelector('.file-name'); - fileNameElement.addEventListener('click', function(e) { - e.stopPropagation(); - makeEditable(e.target); - }); - - // Add click handlers for episode arrow buttons - const arrowLeft = fileItem.querySelector('.episode-arrow-left'); - const arrowRight = fileItem.querySelector('.episode-arrow-right'); - - if (arrowLeft) { - arrowLeft.addEventListener('click', function(e) { - e.stopPropagation(); - handleEpisodeArrowClick(this, 'left'); - }); - } - - if (arrowRight) { - arrowRight.addEventListener('click', function(e) { - e.stopPropagation(); - handleEpisodeArrowClick(this, 'right'); - }); - } - - // Add hover effects for tags - const tagIcons = fileItem.querySelectorAll('.tag-icon'); - tagIcons.forEach(icon => { - icon.addEventListener('mouseenter', function() { - this.style.opacity = '1'; - this.style.transform = 'scale(1.1)'; - }); - - icon.addEventListener('mouseleave', function() { - this.style.opacity = '0.7'; - this.style.transform = 'scale(1)'; - }); - - // Add click handlers for tagging - icon.addEventListener('click', function(e) { - e.stopPropagation(); // Prevent event bubbling - const filePath = this.dataset.filePath; - let tagType; - if (this.classList.contains('extra-tag')) { - tagType = 'extra'; - } else if (this.classList.contains('commentary-tag')) { - tagType = 'commentary'; - } else if (this.classList.contains('delete-tag')) { - tagType = 'delete'; - } else { - return; - } - - // Check if file is already tagged with this type - const fileItem = this.closest('.file-item'); - const isTagged = fileItem.hasAttribute('data-tagged-' + tagType); - - if (isTagged) { - // Untag the file - untagFile(filePath, tagType); - } else { - // If tagging this type, untag any existing tag of other types - ['extra', 'commentary', 'delete'].forEach(otherType => { - if (otherType !== tagType && fileItem.hasAttribute('data-tagged-' + otherType)) { - untagFile(filePath, otherType); - } - }); - // Tag the file - addTagToEpisode(filePath, tagType); - } - }); - }); - - // Add click handler for preview button - const previewBtn = fileItem.querySelector('.video-preview-btn'); - previewBtn.addEventListener('click', function(e) { - e.stopPropagation(); // Prevent event bubbling - const filePath = this.dataset.filePath; - openVideoPreview(filePath); - }); - - // Add click handler for play button - const playButton = fileItem.querySelector('.play-button'); - playButton.addEventListener('click', async function(e) { - e.stopPropagation(); - console.log('Play button clicked for file:', file.path); - - // Get the file item and tag type - const fileItemEl = this.closest('.file-item'); - let tagType = null; - if (fileItemEl.hasAttribute('data-tagged-extra')) { - tagType = 'extra'; - } else if (fileItemEl.hasAttribute('data-tagged-commentary')) { - tagType = 'commentary'; - } else if (fileItemEl.hasAttribute('data-tagged-delete')) { - tagType = 'delete'; - } - - console.log('Tag type determined:', tagType); - if (tagType) { - // Move the file to the appropriate folder - const result = await moveTaggedFile(file.path, tagType); - if (result.success) { - // Remove the item from the file list after successful move - fileItemEl.remove(); - updateTaggedCount(); - updateEpisodeNumbers(); - checkEpisodeCountMatch(); - } - } else { - console.log('No tag type found for file'); - } - }); - - fileListEl.appendChild(fileItem); - episodeNumber++; - }); - - // Check if episode count matches after displaying files - checkEpisodeCountMatch(); -} - -// Drag and drop handlers -let draggedItem = null; - -function handleDragStart(e) { - draggedItem = this; - this.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', this.dataset.index); -} - -function handleDragEnd(e) { - this.classList.remove('dragging'); - // Remove drag-over class from all items - document.querySelectorAll('.file-item').forEach(item => { - item.classList.remove('drag-over'); - }); - draggedItem = null; -} - -function handleDragOver(e) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - - // Only show drag-over for non-folder items - if (!this.classList.contains('folder-item') && this !== draggedItem) { - this.classList.add('drag-over'); - } -} - -function handleDragLeave(e) { - this.classList.remove('drag-over'); -} - -function handleDrop(e) { - e.preventDefault(); - this.classList.remove('drag-over'); - - if (this === draggedItem || this.classList.contains('folder-item')) return; - - // Get all media file items (not folders) - const fileItems = Array.from(fileListEl.querySelectorAll('.file-item:not(.folder-item)')); - const draggedIndex = fileItems.indexOf(draggedItem); - const dropIndex = fileItems.indexOf(this); - - if (draggedIndex === -1 || dropIndex === -1) return; - - // Move the dragged item in the DOM - if (draggedIndex < dropIndex) { - this.parentNode.insertBefore(draggedItem, this.nextSibling); - } else { - this.parentNode.insertBefore(draggedItem, this); - } - - // Update episode numbers - updateEpisodeNumbers(); -} - -function updateEpisodeNumbers() { - if (isUpdatingEpisodeNumbers) { - console.log('[updateEpisodeNumbers] Already updating, skipping re-entrant call'); - return; - } - - isUpdatingEpisodeNumbers = true; - - try { - const fileItems = fileListEl.querySelectorAll('.file-item:not(.folder-item)'); - let episodeNum = 1; - - console.log('[updateEpisodeNumbers] Starting with episodeNum:', episodeNum); - console.log('[updateEpisodeNumbers] Total items:', fileItems.length); - console.trace('[updateEpisodeNumbers] Call stack'); - - fileItems.forEach((item, index) => { - const episodeEl = item.querySelector('.episode-number'); - if (episodeEl) { - const storedStart = episodeEl.dataset.episodeStart; - const storedEnd = episodeEl.dataset.episodeEnd; - console.log(`[updateEpisodeNumbers] Item ${index}: storedStart="${storedStart}", storedEnd="${storedEnd}"`); - - const start = storedStart ? parseInt(storedStart) : episodeNum; - const end = storedEnd ? parseInt(storedEnd) : start; - - const rangeText = start === end ? `${start}` : `${start}-${end}`; - episodeEl.textContent = rangeText; - episodeEl.dataset.episode = episodeNum; - console.log(`[updateEpisodeNumbers] Item ${index}: calculated start=${start}, end=${end}, display=${rangeText}, next episodeNum=${end + 1}`); - episodeNum = end + 1; - } - }); - } finally { - isUpdatingEpisodeNumbers = false; - } -} - -// Make an episode number editable for range editing -function makeEpisodeRangeEditable(element) { - if (element.contentEditable === 'true') return; - - const originalText = element.textContent; - const originalStart = element.dataset.episodeStart; - const originalEnd = element.dataset.episodeEnd; - - element.contentEditable = 'true'; - element.focus(); - element.classList.add('editing'); - - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - - const saveEdit = function() { - const newText = element.textContent.trim(); - - if (newText !== originalText) { - let start, end; - - if (newText.includes('-')) { - const parts = newText.split('-').map(p => parseInt(p.trim())); - start = parts[0]; - end = parts[1] || start; - } else { - const num = parseInt(newText); - start = num; - end = num; - } - - if (!isNaN(start) && !isNaN(end)) { - element.dataset.episodeStart = start; - element.dataset.episodeEnd = end; - console.log(`[makeEpisodeRangeEditable] Episode range updated: ${start}-${end}`); - updateEpisodeNumbers(); - } else { - element.textContent = originalText; - element.dataset.episodeStart = originalStart; - element.dataset.episodeEnd = originalEnd; - } - } - - element.contentEditable = 'false'; - element.classList.remove('editing'); - }; - - element.addEventListener('keydown', function(e) { - if (e.key === 'Enter') { - e.preventDefault(); - saveEdit(); - } - }, { once: true }); - - element.addEventListener('blur', saveEdit, { once: true }); -} - -// Handle episode arrow button clicks -function handleEpisodeArrowClick(element, direction) { - const fileItem = element.closest('.file-item'); - const episodeEl = fileItem.querySelector('.episode-number'); - - if (!episodeEl) return; - - // Get current values before updating - let start = parseInt(episodeEl.dataset.episodeStart) || 1; - let end = parseInt(episodeEl.dataset.episodeEnd) || start; - - const oldStart = start; - const oldEnd = end; - - // Update the episode range - if (direction === 'left') { - // Decrease range - move start back by 1 - if (start > 1) { - start--; - } - } else { - // Increase range - move end forward by 1 - end++; - } - - // Update this episode's data attributes - episodeEl.dataset.episodeStart = start; - episodeEl.dataset.episodeEnd = end; - - // Get all media file items (not folders) in order - const allFileItems = Array.from(fileListEl.querySelectorAll('.file-item:not(.folder-item)')); - - // Find current index by matching file path - const filePath = fileItem.dataset.filePath; - let currentIndex = -1; - for (let i = 0; i < allFileItems.length; i++) { - if (allFileItems[i].dataset.filePath === filePath) { - currentIndex = i; - break; - } - } - - if (currentIndex === -1) return; - - // For right arrow: shift all subsequent episodes forward by 1 - if (direction === 'right') { - for (let i = currentIndex + 1; i < allFileItems.length; i++) { - const nextItem = allFileItems[i]; - const nextEpisodeEl = nextItem.querySelector('.episode-number'); - - if (nextEpisodeEl) { - let nextStart = parseInt(nextEpisodeEl.dataset.episodeStart) || 1; - const nextEnd = parseInt(nextEpisodeEl.dataset.episodeEnd) || nextStart; - - nextEpisodeEl.dataset.episodeStart = nextStart + 1; - nextEpisodeEl.dataset.episodeEnd = nextEnd + 1; - } - } - } else { - // For left arrow: all subsequent episodes shift down - const shiftAmount = oldStart - start; - for (let i = currentIndex + 1; i < allFileItems.length; i++) { - const nextItem = allFileItems[i]; - const nextEpisodeEl = nextItem.querySelector('.episode-number'); - - if (nextEpisodeEl) { - let nextStart = parseInt(nextEpisodeEl.dataset.episodeStart) || 1; - const nextEnd = parseInt(nextEpisodeEl.dataset.episodeEnd) || nextStart; - - nextEpisodeEl.dataset.episodeStart = nextStart - shiftAmount; - nextEpisodeEl.dataset.episodeEnd = nextEnd - shiftAmount; - } - } - } - - // Force immediate DOM update - updateEpisodeNumbers(); -} - -// Make a file name editable -function makeEditable(element) { - // Prevent editing if already in edit mode - if (element.contentEditable === 'true') return; - - // Store original text - const originalText = element.textContent; - - // Make element editable - element.contentEditable = 'true'; - element.focus(); - element.classList.add('editing'); - - // Select all text when editing starts - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - - // Handle saving when user finishes editing - const saveEdit = async function() { - if (element.textContent.trim() !== originalText.trim()) { - // Send request to main process to rename the file - try { - const result = await ipcRenderer.invoke('rename-file', { - oldPath: element.dataset.filePath, - newName: element.textContent.trim() - }); - - if (result.success) { - console.log('File renamed successfully:', result.message); - // Update the file name in the UI to reflect the change - element.textContent = element.textContent.trim(); - - // Also update the data attribute to reflect the new path - const oldPath = element.dataset.filePath; - const newPath = oldPath.substring(0, oldPath.lastIndexOf(path.sep) + 1) + element.textContent.trim(); - element.dataset.filePath = newPath; - } else { - console.error('Failed to rename file:', result.error); - // Revert to original name on failure - element.textContent = originalText; - } - } catch (error) { - console.error('Error renaming file:', error); - // Revert to original name on error - element.textContent = originalText; - } - } - - // Clean up - element.contentEditable = 'false'; - element.classList.remove('editing'); - }; - - // Save on Enter key or blur - element.addEventListener('keydown', function(e) { - if (e.key === 'Enter') { - e.preventDefault(); - saveEdit(); - } - }); - - element.addEventListener('blur', saveEdit); -} - -// Function to handle tagging -function addTagToEpisode(filePath, tagType) { - console.log(`Tagging file ${filePath} as ${tagType}`); - - // Find the file item in the UI - const fileItems = document.querySelectorAll('.file-item'); - fileItems.forEach(item => { - const fileNameElement = item.querySelector('.file-name'); - if (fileNameElement && fileNameElement.dataset.filePath === filePath) { - // Add visual indication of tagging using data attribute for CSS styling - const tagIcon = item.querySelector(`.${tagType}-tag`); - if (tagIcon) { - // Set color based on tag type - let tagColor; - if (tagType === 'extra') { - tagColor = '#FFD700'; // Yellow - } else if (tagType === 'commentary') { - tagColor = '#17a2b8'; // Teal - } else if (tagType === 'delete') { - tagColor = '#dc3545'; // Red - } - - // Make the icon fully saturated and highlight - tagIcon.style.opacity = '1'; - tagIcon.style.filter = 'none'; - tagIcon.style.color = tagColor; - tagIcon.style.textShadow = `0 0 15px ${tagColor}`; - tagIcon.style.transform = 'scale(1.3)'; - - // Add a data attribute to track that this file is tagged - item.setAttribute('data-tagged-' + tagType, 'true'); - - // Enable the play button - const playButton = item.querySelector('.play-button'); - if (playButton) { - playButton.style.opacity = '1'; - playButton.style.cursor = 'pointer'; - playButton.disabled = false; - playButton.style.pointerEvents = 'auto'; - } - } - } - }); - - // Update the tagged count display - updateTaggedCount(); - - // In a real implementation, this would save to a database or file - // For now, we'll just log to console - console.log(`File ${filePath} tagged as ${tagType} - would be saved in real implementation`); -} - -// Function to handle moving tagged files (also logs audit) -function moveTaggedFile(filePath, tagType) { - console.log(`[RENDERER] Moving file ${filePath} to ${tagType} folder`); - - // Log audit event - logAuditEvent('move_file', { - filePath: filePath, - folderName: tagType - }); - - // Send request to main process to move the file - return ipcRenderer.invoke('move-file-to-folder', { - filePath: filePath, - folderName: tagType - }).then(result => { - console.log(`[RENDERER] Move result:`, result); - if (result.success) { - console.log(`[RENDERER] File moved successfully to ${tagType} folder`); - // Show visual feedback that directory was created - showDirectoryFeedback(tagType); - return { success: true, filePath }; - } else { - console.error(`[RENDERER] Failed to move file: ${result.error}`); - return { success: false, error: result.error, filePath }; - } - }).catch(error => { - console.error('[RENDERER] Error moving file:', error); - return { success: false, error: error.message, filePath }; - }); -} - -// Function to move all tagged files at once -async function moveAllTaggedFiles() { - const taggedItems = document.querySelectorAll('.file-item[data-tagged-extra], .file-item[data-tagged-commentary], .file-item[data-tagged-delete]'); - - if (taggedItems.length === 0) { - console.log('[RENDERER] No tagged files to move'); - return; - } - - console.log(`[RENDERER] Moving ${taggedItems.length} tagged files`); - - // Visual feedback - change circle color while processing - const taggedCircle = document.getElementById('tagged-circle'); - if (taggedCircle) { - taggedCircle.style.backgroundColor = '#ffc107'; - taggedCircle.style.pointerEvents = 'none'; - } - - const results = []; - for (const item of taggedItems) { - const filePath = item.querySelector('.file-name').dataset.filePath; - let tagType; - if (item.hasAttribute('data-tagged-extra')) { - tagType = 'extra'; - } else if (item.hasAttribute('data-tagged-commentary')) { - tagType = 'commentary'; - } else if (item.hasAttribute('data-tagged-delete')) { - tagType = 'delete'; - } - - const result = await moveTaggedFile(filePath, tagType); - results.push(result); - - if (result.success) { - // Remove the item from the file list after successful move - item.remove(); - } - } - - // Reset circle appearance - if (taggedCircle) { - taggedCircle.style.backgroundColor = ''; - taggedCircle.style.pointerEvents = ''; - } - - // Update the tagged count after all moves - updateTaggedCount(); - updateEpisodeNumbers(); - checkEpisodeCountMatch(); - - // Summary of results - const successful = results.filter(r => r.success).length; - const failed = results.filter(r => !r.success).length; - - if (failed > 0) { - alert(`Moved ${successful} files. ${failed} files failed to move.`); - } else if (successful > 0) { - console.log(`[RENDERER] Successfully moved all ${successful} files`); - } -} - -// Function to begin mapping files to Jellyfin naming convention -async function beginMapping() { - const btn = document.getElementById('begin-mapping-btn'); - - // Get all media file items (not folders) - const fileItems = document.querySelectorAll('.file-item:not(.folder-item)'); - - if (fileItems.length === 0) { - alert('No media files to map. Please select a directory first.'); - return; - } - - if (!currentDirectory) { - alert('No directory selected.'); - return; - } - - // Disable button during processing - btn.disabled = true; - btn.textContent = 'Mapping...'; - - // Collect file data with episode ranges from UI order - const filesToMap = []; - fileItems.forEach((item, index) => { - const fileNameEl = item.querySelector('.file-name'); - const qualityEl = item.querySelector('.file-quality'); - const episodeNumEl = item.querySelector('.episode-number'); - - if (fileNameEl && fileNameEl.dataset.filePath) { - const episodeStart = episodeNumEl ? parseInt(episodeNumEl.dataset.episodeStart || (index + 1)) : (index + 1); - const episodeEnd = episodeNumEl ? parseInt(episodeNumEl.dataset.episodeEnd || episodeStart) : episodeStart; - - filesToMap.push({ - filePath: fileNameEl.dataset.filePath, - quality: qualityEl ? qualityEl.textContent : '', - episodeStart: episodeStart, - episodeEnd: episodeEnd - }); - } - }); - - // Get TVDB ID from current show if selected - const tvdbId = currentShow ? currentShow.id : null; - - try { - const result = await ipcRenderer.invoke('begin-mapping', { - directory: currentDirectory, - files: filesToMap, - tvdbId: tvdbId - }); - - if (result.success) { - alert('Mapping Complete!'); - // Update currentDirectory if it changed (show folder was renamed) - const newDir = result.newDirectory || currentDirectory; - currentDirectory = newDir; - // Refresh the file list to show new names - openDirectory(newDir); - } else { - alert('Mapping failed: ' + (result.error || 'Unknown error')); - } - } catch (error) { - console.error('Mapping error:', error); - alert('Mapping failed: ' + error.message); - } finally { - btn.disabled = false; - btn.textContent = 'Begin Mapping'; - } -} - -// Function to display created folders in UI -function displayCreatedFolder(folderName) { - // Check if folder entry already exists - const existingFolder = document.querySelector(`.folder-item[data-folder-name="${folderName}"]`); - if (existingFolder) { - // Update the file count - const countEl = existingFolder.querySelector('.folder-count'); - if (countEl) { - const currentCount = parseInt(countEl.textContent) || 0; - countEl.textContent = currentCount + 1; - } - return; - } - - // Create folder item element - const folderItem = document.createElement('div'); - folderItem.className = 'folder-item'; - folderItem.setAttribute('data-folder-name', folderName); - - let displayName, folderIcon, folderColor; - if (folderName === 'extra') { - displayName = 'extras'; - folderIcon = '📁'; - folderColor = '#FFD700'; - } else if (folderName === 'commentary') { - displayName = 'commentary'; - folderIcon = '💬'; - folderColor = '#17a2b8'; - } else if (folderName === 'delete') { - displayName = 'delete'; - folderIcon = '🗑️'; - folderColor = '#dc3545'; - } - - folderItem.innerHTML = ` -
${folderIcon}
-
${displayName}
-
1
- `; - - folderItem.style.cssText = ` - display: flex; - align-items: center; - gap: 10px; - padding: 12px; - margin-bottom: 8px; - background-color: #16213e; - border: 2px solid ${folderColor}; - border-radius: 8px; - cursor: pointer; - `; - - // Insert at the top of the file list - if (fileListEl.firstChild) { - fileListEl.insertBefore(folderItem, fileListEl.firstChild); - } else { - fileListEl.appendChild(folderItem); - } - - console.log(`Folder "${displayName}" displayed in UI`); -} - -// Function to show visual feedback for directory creation -function showDirectoryFeedback(folderName) { - console.log(`Directory "${folderName}" created and file moved`); - // Display the folder in the UI - displayCreatedFolder(folderName); -} - -// Function to handle untagging -function untagFile(filePath, tagType) { - console.log(`Untagging file ${filePath} from ${tagType}`); - - // Find the file item in the UI - const fileItems = document.querySelectorAll('.file-item'); - fileItems.forEach(item => { - const fileNameElement = item.querySelector('.file-name'); - if (fileNameElement && fileNameElement.dataset.filePath === filePath) { - // Remove visual indication of tagging - const tagIcon = item.querySelector(`.${tagType}-tag`); - if (tagIcon) { - // Reset to original appearance - tagIcon.style.opacity = '0.7'; - tagIcon.style.filter = 'none'; - tagIcon.style.color = ''; // Reset to default color - tagIcon.style.textShadow = 'none'; - tagIcon.style.transform = 'scale(1)'; - tagIcon.style.boxShadow = 'none'; - - // Remove the data attribute - item.removeAttribute('data-tagged-' + tagType); - - // Disable the play button when untagging (only if no other tags) - const hasOtherTags = item.hasAttribute('data-tagged-extra') || - item.hasAttribute('data-tagged-commentary') || - item.hasAttribute('data-tagged-delete'); - if (!hasOtherTags) { - const playButton = item.querySelector('.play-button'); - if (playButton) { - playButton.style.opacity = '0.3'; - playButton.style.cursor = 'default'; - playButton.disabled = true; - playButton.style.pointerEvents = 'none'; - } - } - } - } - }); - - // Update the tagged count display - updateTaggedCount(); - - // In a real implementation, this would remove from database or file - // For now, we'll just log to console - console.log(`File ${filePath} untagged from ${tagType} - would be removed in real implementation`); -} - -// Function to update the tagged items count display -function updateTaggedCount() { - const taggedItems = document.querySelectorAll('.file-item[data-tagged-extra], .file-item[data-tagged-commentary], .file-item[data-tagged-delete]'); - const count = taggedItems.length; - const taggedCircle = document.getElementById('tagged-circle'); - const taggedCount = document.getElementById('tagged-count'); - - if (taggedCount) { - taggedCount.textContent = count; - } - - // Animate the circle position based on count - if (taggedCircle) { - if (count === 0) { - // Move down out of sight - taggedCircle.style.transform = 'translateY(100px)'; - taggedCircle.style.bottom = '-100px'; - } else { - // Move up to show count (appears at the same spot at bottom right) - taggedCircle.style.transform = 'translateY(0)'; - taggedCircle.style.bottom = '20px'; - } - } -} - -// Function to check if media file count matches selected season episode count -function checkEpisodeCountMatch() { - const fileList = document.getElementById('file-list'); - // Count only actual media files (not folders) - const mediaFiles = document.querySelectorAll('.file-item:not(.folder-item)'); - - // Calculate total episode range (sum of all episode ranges) - let totalEpisodeCount = 0; - let lastEpisodeEnd = 0; - - mediaFiles.forEach((item, index) => { - const episodeEl = item.querySelector('.episode-number'); - if (episodeEl) { - const episodeStart = parseInt(episodeEl.dataset.episodeStart || (index + 1)); - const episodeEnd = parseInt(episodeEl.dataset.episodeEnd || episodeStart); - - // Calculate episodes in this range - const rangeSize = episodeEnd - episodeStart + 1; - totalEpisodeCount += rangeSize; - - // Track the last episode end for sequential checking - if (index === 0 || episodeEnd > lastEpisodeEnd) { - lastEpisodeEnd = episodeEnd; - } - } - }); - - // Use lastEpisodeEnd for comparison (handles ranges properly) - // For example: if we have episodes 1-3, 4-5, the last episode is 5, not 2 files - if (selectedSeasonEpisodeCount > 0 && lastEpisodeEnd === selectedSeasonEpisodeCount) { - // Perfect match - add green outline - fileList.style.border = '3px solid #28a745'; - fileList.style.boxShadow = '0 0 15px rgba(40, 167, 69, 0.4)'; - } else { - // No match or no season selected - remove green outline - fileList.style.border = ''; - fileList.style.boxShadow = ''; - } -} - -// Initialize tagged count on page load -document.addEventListener('DOMContentLoaded', function() { - updateTaggedCount(); -}); - -// Function to log audit events to the directory's .audit file -function logAuditEvent(action, details) { - // This function will be called from the renderer process to notify main process - console.log(`[AUDIT] ${action}:`, details); - // In a real implementation, this would send an IPC message to main process to log audit -} - -// Search shows function -async function searchShows() { - const query = searchInput.value.trim(); - - if (query.length < 2) { - searchResultsEl.innerHTML = ''; - return; - } - - try { - const result = await ipcRenderer.invoke('search-tvdb', query); - - if (result.success) { - displaySearchResults(result.results); - } else { - throw new Error(result.error); - } - } catch (error) { - console.error('Error searching shows:', error); - searchResultsEl.innerHTML = `

Error: ${error.message}

`; - } -} - -// Clear search results when a show is selected -function clearSearchResults() { - searchResultsEl.innerHTML = ''; -} - -// Test TVDB API connectivity -async function testTVDBAPI() { - try { - const result = await ipcRenderer.invoke('test-tvdb-api'); - console.log('TVDB API Test:', result); - if (result.success) { - console.log('TVDB API is working:', result.message); - alert('TVDB API Test Successful!\n' + result.message); - } else { - console.error('TVDB API test failed:', result.error); - alert('TVDB API Test Failed!\n' + result.error); - } - } catch (error) { - console.error('Error testing TVDB API:', error); - alert('Error testing TVDB API: ' + error.message); - } -} - -// Add a test button to the UI (you can add this to the HTML or call it manually) -// For now, we'll just make it available in the console -console.log('TVDB API test function available: testTVDBAPI()'); - -// Debug function to log problematic file info -async function debugProblematicFile(filePath) { - try { - const result = await ipcRenderer.invoke('log-file-info', filePath); - if (result.success) { - console.log('File info:', result.fileInfo); - } else { - console.error('Failed to get file info:', result.error); - } - } catch (error) { - console.error('Error debugging file:', error); - } -} - -// Display search results -function displaySearchResults(results) { - searchResultsEl.innerHTML = ''; - - if (results.length === 0) { - searchResultsEl.innerHTML = '

No shows found.

'; - return; - } - - results.forEach(show => { - const resultItem = document.createElement('div'); - resultItem.className = 'search-result-item'; - - // Create a more detailed display with separate elements - const showName = show.seriesName || show.name; - const showYear = show.firstAired ? show.firstAired.split('-')[0] : ''; - - resultItem.innerHTML = ` -
${showName}
- ${showYear ? `
${showYear}
` : ''} - `; - - resultItem.addEventListener('click', () => selectShow(show)); - searchResultsEl.appendChild(resultItem); - }); -} - -// Select a show and display more details -async function selectShow(show) { - console.log('Selected show:', show); - currentShow = show; - - // Clear search results when a show is selected - clearSearchResults(); - - // Display show title - document.getElementById('show-title').textContent = show.seriesName || show.name; - - // Fetch show details including seasons - try { - const result = await ipcRenderer.invoke('get-show-details', show.id); - - if (result.success && result.data) { - const showData = result.data; - currentSeasons = showData.seasons || []; - - // Display seasons - since we're not getting seasons from the API directly, - // we'll show a message indicating we need to fetch seasons separately - if (currentSeasons.length === 0) { - document.getElementById('seasons-container').innerHTML = '

Seasons will be loaded when you click on a season.

'; - } else { - displaySeasons(currentSeasons); - } - } else { - console.error('Failed to fetch show details:', result.error); - document.getElementById('seasons-container').innerHTML = '

Error loading seasons: ' + (result.error || 'Unknown error') + '

'; - } - } catch (error) { - console.error('Error fetching show details:', error); - document.getElementById('seasons-container').innerHTML = '

Error loading seasons: ' + error.message + '

'; - } -} - -// Display seasons with episode counts -function displaySeasons(seasons) { - const seasonsContainer = document.getElementById('seasons-container'); - seasonsContainer.innerHTML = ''; - - if (!seasons || seasons.length === 0) { - seasonsContainer.innerHTML = '

No seasons available.

'; - return; - } - - seasons.forEach(season => { - const seasonItem = document.createElement('div'); - seasonItem.className = 'season-item'; - - // Format season display with episode count - let seasonDisplay = `Season ${season.number}`; - if (season.type && season.type !== 'Unknown') { - seasonDisplay += ` (${season.type})`; - } - - const episodeCount = season.episodeCount || 0; - - seasonItem.innerHTML = ` -
- ${seasonDisplay} - - ${episodeCount} eps - -
- `; - - // Add click event to fetch episodes - seasonItem.addEventListener('click', () => { - // Remove selected class from all seasons - document.querySelectorAll('.season-item').forEach(item => item.classList.remove('selected')); - // Add selected class to clicked season - seasonItem.classList.add('selected'); - fetchAndDisplayEpisodes(currentShow.id, season.number); - }); - - seasonsContainer.appendChild(seasonItem); - }); -} - -// Fetch and display episodes for a season -async function fetchAndDisplayEpisodes(showId, seasonNumber) { - try { - // Show loading message while fetching - document.getElementById('seasons-container').innerHTML = '

Loading episodes...

'; - - const result = await ipcRenderer.invoke('get-season-episodes', showId, seasonNumber); - - if (result.success && result.data) { - const episodes = result.data.episodes || []; - displayEpisodes(episodes); - } else { - console.error('Failed to fetch episodes:', result.error); - document.getElementById('seasons-container').innerHTML = '

Error loading episodes: ' + (result.error || 'Unknown error') + '

'; - } - } catch (error) { - console.error('Error fetching episodes:', error); - // Show a more user-friendly error message - document.getElementById('seasons-container').innerHTML = ` -
-

Error loading episodes: ${error.message || 'Failed to load episodes'}

-

Note: This may be due to API limitations with the TVDB v4 API.

-
- `; - } -} - -// Display episodes in clean table format -function displayEpisodes(episodes) { - const seasonsContainer = document.getElementById('seasons-container'); - seasonsContainer.innerHTML = ''; - - if (!episodes || episodes.length === 0) { - seasonsContainer.innerHTML = '

No episodes available.

'; - selectedSeasonEpisodeCount = 0; - checkEpisodeCountMatch(); - return; - } - - // Store the episode count for this season - selectedSeasonEpisodeCount = episodes.length; - checkEpisodeCountMatch(); - - // Create a back button to return to seasons - const backButton = document.createElement('div'); - backButton.className = 'season-item'; - backButton.innerHTML = '← Back to Seasons'; - backButton.addEventListener('click', () => { - selectedSeasonEpisodeCount = 0; - checkEpisodeCountMatch(); - displaySeasons(currentSeasons); - }); - seasonsContainer.appendChild(backButton); - - // Create episodes container - const episodesContainer = document.createElement('div'); - episodesContainer.id = 'episodes-container'; - - episodes.forEach(episode => { - const episodeRow = document.createElement('div'); - episodeRow.className = 'episode-item'; - - // Format episode display - const episodeName = episode.name || 'Untitled'; - const episodeRuntime = episode.runtime ? `${episode.runtime}m` : ''; - - episodeRow.innerHTML = ` -
- E${episode.number} ${episodeName} - ${episodeRuntime ? `${episodeRuntime}` : ''} -
- `; - - episodesContainer.appendChild(episodeRow); - }); - - seasonsContainer.appendChild(episodesContainer); - - // Also update the main file list to show episode matching information - updateFileListWithEpisodeInfo(episodes); -} - -// Enhanced function to create a more integrated view -function createIntegratedEpisodeFileView(episodes) { - // This function would create a more integrated view showing both files and episodes - // For now, we're enhancing the existing functionality to better align the views - - // Add a section that shows how files might align with episodes - const fileListContainer = document.getElementById('file-list'); - - // Create a section showing the relationship - const relationshipSection = document.createElement('div'); - relationshipSection.style.marginTop = '15px'; - relationshipSection.style.padding = '12px'; - relationshipSection.style.backgroundColor = '#fff8e1'; - relationshipSection.style.border = '1px solid #ffd54f'; - relationshipSection.style.borderRadius = '6px'; - relationshipSection.innerHTML = ` -

File-Episode Relationship

-

- Matching Strategy: Files are matched to episodes based on naming patterns. -

-

- Example: "Show.S01E01.Title.mp4" matches Episode 1 of Season 1. -

-

- Status: ${currentFiles.length} files in directory, ${episodes.length} episodes available. -

- `; - - // Insert this section after the episode info - const episodeInfo = fileListContainer.querySelector('.episode-info'); - if (episodeInfo) { - fileListContainer.insertBefore(relationshipSection, episodeInfo.nextSibling); - } -} - -// Update file list to show episode matching information -function updateFileListWithEpisodeInfo(episodes) { - // When we have episodes, we want to show basic episode information - if (currentFiles && currentFiles.length > 0 && episodes && episodes.length > 0) { - console.log('Updating file list with episode info for', episodes.length, 'episodes'); - - // Create a simple note about episode availability - const fileListContainer = document.getElementById('file-list'); - - // Add a simple note about episodes - const episodeNote = document.createElement('div'); - episodeNote.style.marginTop = '15px'; - episodeNote.style.padding = '10px'; - episodeNote.style.backgroundColor = '#e7f3ff'; - episodeNote.style.border = '1px solid #b3d9ff'; - episodeNote.style.borderRadius = '5px'; - episodeNote.style.fontSize = '14px'; - episodeNote.innerHTML = ` - Episode Information: - ${episodes.length} episodes available for this show. -
Episode details are displayed in the sidebar. - `; - - // Add this note to the file list container - if (fileListContainer.firstChild) { - fileListContainer.insertBefore(episodeNote, fileListContainer.firstChild); - } else { - fileListContainer.appendChild(episodeNote); - } - } -} - -// Debounce function for search input -function debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -} - -// Modal element for video preview -let videoPreviewModal = null; - -// Initialize the application -console.log('Movie Mapper application initialized'); - -// Function to open video preview modal -function openVideoPreview(filePath) { - console.log('Opening video preview for:', filePath); - - // Create modal if it doesn't exist - if (!videoPreviewModal) { - createVideoPreviewModal(); - } - - // Show the modal - videoPreviewModal.style.display = 'block'; - - // Load video content - loadVideoPreview(filePath); -} - -// Function to create video preview modal -function createVideoPreviewModal() { - // Create modal container - videoPreviewModal = document.createElement('div'); - videoPreviewModal.id = 'video-preview-modal'; - videoPreviewModal.style.cssText = ` - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.9); - z-index: 1000; - justify-content: center; - align-items: center; - overflow: auto; - `; - - // Create modal content - const modalContent = document.createElement('div'); - modalContent.style.cssText = ` - position: relative; - max-width: 90%; - max-height: 90%; - background-color: #fff; - border-radius: 8px; - padding: 20px; - margin: 20px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); - `; - - // Create close button - const closeButton = document.createElement('span'); - closeButton.innerHTML = '×'; - closeButton.style.cssText = ` - position: absolute; - top: 10px; - right: 15px; - font-size: 30px; - font-weight: bold; - color: #aaa; - cursor: pointer; - transition: color 0.3s; - `; - - closeButton.addEventListener('mouseenter', function() { - this.style.color = '#000'; - }); - - closeButton.addEventListener('click', function() { - videoPreviewModal.style.display = 'none'; - }); - - // Create video container - const videoContainer = document.createElement('div'); - videoContainer.id = 'video-preview-container'; - videoContainer.style.cssText = ` - text-align: center; - margin-bottom: 15px; - `; - - // Create file info display - const fileInfo = document.createElement('div'); - fileInfo.id = 'video-preview-file-info'; - fileInfo.style.cssText = ` - text-align: center; - margin-bottom: 15px; - padding: 10px; - background-color: #f8f9fa; - border-radius: 5px; - font-size: 14px; - `; - - // Create loading indicator - const loadingIndicator = document.createElement('div'); - loadingIndicator.id = 'video-preview-loading'; - loadingIndicator.textContent = 'Loading video preview...'; - loadingIndicator.style.cssText = ` - text-align: center; - padding: 20px; - font-size: 16px; - color: #666; - `; - - // Assemble modal - modalContent.appendChild(closeButton); - modalContent.appendChild(fileInfo); - modalContent.appendChild(videoContainer); - modalContent.appendChild(loadingIndicator); - videoPreviewModal.appendChild(modalContent); - - // Add click outside to close - videoPreviewModal.addEventListener('click', function(e) { - if (e.target === videoPreviewModal) { - videoPreviewModal.style.display = 'none'; - } - }); - - // Add to body - document.body.appendChild(videoPreviewModal); -} - -// Function to load video preview -async function loadVideoPreview(filePath) { - const fileInfo = document.getElementById('video-preview-file-info'); - const videoContainer = document.getElementById('video-preview-container'); - const loadingIndicator = document.getElementById('video-preview-loading'); - - // Show loading - loadingIndicator.style.display = 'block'; - videoContainer.innerHTML = ''; - fileInfo.innerHTML = `Loading: ${filePath.split('/').pop()}`; - - try { - // Check if file exists const fs = require('fs'); - if (!fs.existsSync(filePath)) { - throw new Error('File not found'); - } - - // Get file stats for info - const stats = fs.statSync(filePath); - const fileName = filePath.split('/').pop(); - - // Update file info - fileInfo.innerHTML = ` - File: ${fileName}
- Size: ${(stats.size / (1024 * 1024)).toFixed(2)} MB
- Path: ${filePath} - `; - - // For MKV files, open directly in default player - const ext = filePath.toLowerCase().split('.').pop(); - if (ext === 'mkv') { - // Close the modal and open in default player directly - videoPreviewModal.style.display = 'none'; - ipcRenderer.invoke('open-file-in-player', filePath); + if (fs.existsSync(oldPath) && oldPath !== newPath) { + fs.renameSync(oldPath, newPath); + return { success: true, message: 'File renamed successfully' }; } else { - // For other formats, try to create a video player - videoContainer.innerHTML = ` - - `; - - // Hide loading - loadingIndicator.style.display = 'none'; + return { success: false, error: 'File not found or paths are the same' }; } } catch (error) { - console.error('Error loading video preview:', error); - fileInfo.innerHTML = `Error: Could not load preview for ${filePath}`; - videoContainer.innerHTML = '

Error loading video preview

'; - loadingIndicator.style.display = 'none'; + return { success: false, error: error.message }; } -} +}); + +console.log('Movie Mapper renderer loaded'); \ No newline at end of file diff --git a/utils/renderer/AppState.js b/utils/renderer/AppState.js new file mode 100644 index 0000000..c5b86d0 --- /dev/null +++ b/utils/renderer/AppState.js @@ -0,0 +1,143 @@ +const path = require('path'); + +/** + * AppState - Manages application state + */ +class AppState { + constructor() { + this.currentDirectory = null; + this.currentFiles = []; + this.currentShow = null; + this.currentSeasons = []; + this.currentEpisodes = []; + this.selectedSeasonEpisodeCount = 0; + this.isUpdatingEpisodeNumbers = false; + } + + /** + * Set the current directory + * @param {string} directory - Directory path + */ + setCurrentDirectory(directory) { + this.currentDirectory = directory; + } + + /** + * Get the current directory + * @returns {string|null} Current directory path + */ + getCurrentDirectory() { + return this.currentDirectory; + } + + /** + * Set current files + * @param {Array} files - Array of file objects + */ + setCurrentFiles(files) { + this.currentFiles = files; + } + + /** + * Get current files + * @returns {Array} Array of file objects + */ + getCurrentFiles() { + return this.currentFiles; + } + + /** + * Set current show + * @param {Object} show - Show object + */ + setCurrentShow(show) { + this.currentShow = show; + } + + /** + * Get current show + * @returns {Object|null} Current show object + */ + getCurrentShow() { + return this.currentShow; + } + + /** + * Set current seasons + * @param {Array} seasons - Array of season objects + */ + setCurrentSeasons(seasons) { + this.currentSeasons = seasons; + } + + /** + * Get current seasons + * @returns {Array} Array of season objects + */ + getCurrentSeasons() { + return this.currentSeasons; + } + + /** + * Set current episodes + * @param {Array} episodes - Array of episode objects + */ + setCurrentEpisodes(episodes) { + this.currentEpisodes = episodes; + } + + /** + * Get current episodes + * @returns {Array} Array of episode objects + */ + getCurrentEpisodes() { + return this.currentEpisodes; + } + + /** + * Set selected season episode count + * @param {number} count - Episode count + */ + setSelectedSeasonEpisodeCount(count) { + this.selectedSeasonEpisodeCount = count; + } + + /** + * Get selected season episode count + * @returns {number} Episode count + */ + getSelectedSeasonEpisodeCount() { + return this.selectedSeasonEpisodeCount; + } + + /** + * Check if episode numbers are currently updating + * @returns {boolean} True if updating + */ + isUpdatingEpisodeNumbers() { + return this.isUpdatingEpisodeNumbers; + } + + /** + * Set episode numbers updating flag + * @param {boolean} updating - Updating state + */ + setUpdatingEpisodeNumbers(updating) { + this.isUpdatingEpisodeNumbers = updating; + } + + /** + * Reset state to initial values + */ + reset() { + this.currentDirectory = null; + this.currentFiles = []; + this.currentShow = null; + this.currentSeasons = []; + this.currentEpisodes = []; + this.selectedSeasonEpisodeCount = 0; + this.isUpdatingEpisodeNumbers = false; + } +} + +module.exports = AppState; \ No newline at end of file diff --git a/utils/renderer/EpisodeManager.js b/utils/renderer/EpisodeManager.js new file mode 100644 index 0000000..bda2431 --- /dev/null +++ b/utils/renderer/EpisodeManager.js @@ -0,0 +1,285 @@ +/** + * EpisodeManager - Manages episode number editing and highlighting + */ +class EpisodeManager { + constructor() { + this.isUpdatingEpisodeNumbers = false; + } + + /** + * Update episode numbers in the file list + * @param {HTMLElement} fileListEl - File list element + */ + updateEpisodeNumbers(fileListEl) { + if (this.isUpdatingEpisodeNumbers) { + console.log('[updateEpisodeNumbers] Already updating, skipping re-entrant call'); + return; + } + + this.isUpdatingEpisodeNumbers = true; + + try { + const fileItems = fileListEl.querySelectorAll('.file-item:not(.folder-item)'); + let episodeNum = 1; + + fileItems.forEach((item, index) => { + const episodeEl = item.querySelector('.episode-number'); + if (episodeEl) { + const storedStart = episodeEl.dataset.episodeStart; + const storedEnd = episodeEl.dataset.episodeEnd; + + const start = storedStart ? parseInt(storedStart) : episodeNum; + const end = storedEnd ? parseInt(storedEnd) : start; + + const rangeText = start === end ? `${start}` : `${start}-${end}`; + episodeEl.textContent = rangeText; + episodeEl.dataset.episode = episodeNum; + episodeNum = end + 1; + } + }); + } finally { + this.isUpdatingEpisodeNumbers = false; + } + } + + /** + * Make episode range editable + * @param {HTMLElement} element - Episode number element + */ + makeEpisodeRangeEditable(element) { + if (element.contentEditable === 'true') return; + + const originalText = element.textContent; + const originalStart = element.dataset.episodeStart; + const originalEnd = element.dataset.episodeEnd; + + element.contentEditable = 'true'; + element.focus(); + element.classList.add('editing'); + + const range = document.createRange(); + range.selectNodeContents(element); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + const saveEdit = () => { + const newText = element.textContent.trim(); + + if (newText !== originalText) { + let start, end; + + if (newText.includes('-')) { + const parts = newText.split('-').map(p => parseInt(p.trim())); + start = parts[0]; + end = parts[1] || start; + } else { + const num = parseInt(newText); + start = num; + end = num; + } + + if (!isNaN(start) && !isNaN(end)) { + element.dataset.episodeStart = start; + element.dataset.episodeEnd = end; + console.log(`Episode range updated: ${start}-${end}`); + this.updateEpisodeNumbers(element.closest('#file-list')); + } else { + element.textContent = originalText; + element.dataset.episodeStart = originalStart; + element.dataset.episodeEnd = originalEnd; + } + } + + element.contentEditable = 'false'; + element.classList.remove('editing'); + }; + + element.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + saveEdit(); + } + }, { once: true }); + + element.addEventListener('blur', saveEdit, { once: true }); + } + + /** + * Handle episode arrow click + * @param {HTMLElement} element - Arrow element + * @param {string} direction - Direction (left or right) + * @param {HTMLElement} fileListEl - File list element + */ + handleEpisodeArrowClick(element, direction, fileListEl) { + const fileItem = element.closest('.file-item'); + const episodeEl = fileItem.querySelector('.episode-number'); + + if (!episodeEl) return; + + // Get current values before updating + let start = parseInt(episodeEl.dataset.episodeStart) || 1; + let end = parseInt(episodeEl.dataset.episodeEnd) || start; + + const oldStart = start; + const oldEnd = end; + + // Update the episode range + if (direction === 'left') { + // Decrease range - move start back by 1 + if (start > 1) { + start--; + } + } else { + // Increase range - move end forward by 1 + end++; + } + + // Update this episode's data attributes + episodeEl.dataset.episodeStart = start; + episodeEl.dataset.episodeEnd = end; + + // Get all media file items (not folders) in order + const allFileItems = Array.from(fileListEl.querySelectorAll('.file-item:not(.folder-item)')); + + // Find current index by matching file path + const filePath = fileItem.dataset.filePath; + let currentIndex = -1; + for (let i = 0; i < allFileItems.length; i++) { + if (allFileItems[i].dataset.filePath === filePath) { + currentIndex = i; + break; + } + } + + if (currentIndex === -1) return; + + // For right arrow: shift all subsequent episodes forward by 1 + if (direction === 'right') { + for (let i = currentIndex + 1; i < allFileItems.length; i++) { + const nextItem = allFileItems[i]; + const nextEpisodeEl = nextItem.querySelector('.episode-number'); + + if (nextEpisodeEl) { + let nextStart = parseInt(nextEpisodeEl.dataset.episodeStart) || 1; + const nextEnd = parseInt(nextEpisodeEl.dataset.episodeEnd) || nextStart; + + nextEpisodeEl.dataset.episodeStart = nextStart + 1; + nextEpisodeEl.dataset.episodeEnd = nextEnd + 1; + } + } + } else { + // For left arrow: all subsequent episodes shift down + const shiftAmount = oldStart - start; + for (let i = currentIndex + 1; i < allFileItems.length; i++) { + const nextItem = allFileItems[i]; + const nextEpisodeEl = nextItem.querySelector('.episode-number'); + + if (nextEpisodeEl) { + let nextStart = parseInt(nextEpisodeEl.dataset.episodeStart) || 1; + const nextEnd = parseInt(nextEpisodeEl.dataset.episodeEnd) || nextStart; + + nextEpisodeEl.dataset.episodeStart = nextStart - shiftAmount; + nextEpisodeEl.dataset.episodeEnd = nextEnd - shiftAmount; + } + } + } + + // Force immediate DOM update + this.updateEpisodeNumbers(fileListEl); + } + + /** + * Highlight episodes in the episodes container + * @param {number} startEpisode - Start episode number + * @param {number} endEpisode - End episode number + * @param {HTMLElement} episodesContainer - Episodes container element + */ + highlightEpisodes(startEpisode, endEpisode, episodesContainer) { + if (!episodesContainer) return; + + const episodeItems = episodesContainer.querySelectorAll('.episode-item'); + + episodeItems.forEach(item => { + const episodeText = item.textContent.trim(); + const episodeMatch = episodeText.match(/E(\d+)/); + + if (episodeMatch) { + const episodeNum = parseInt(episodeMatch[1]); + + if (episodeNum >= startEpisode && episodeNum <= endEpisode) { + item.classList.add('highlighted-episode'); + } + } + }); + } + + /** + * Remove highlights from all episodes + * @param {HTMLElement} episodesContainer - Episodes container element + */ + removeEpisodeHighlights(episodesContainer) { + if (!episodesContainer) return; + + const episodeItems = episodesContainer.querySelectorAll('.episode-item'); + + episodeItems.forEach(item => { + item.classList.remove('highlighted-episode'); + }); + } + + /** + * Get episode range from element + * @param {HTMLElement} episodeEl - Episode element + * @returns {Object} Episode range object + */ + getEpisodeRange(episodeEl) { + const episodeStart = parseInt(episodeEl.dataset.episodeStart) || 1; + const episodeEnd = parseInt(episodeEl.dataset.episodeEnd) || episodeStart; + return { start: episodeStart, end: episodeEnd }; + } + + /** + * Calculate total episode count from file items + * @param {HTMLElement} fileListEl - File list element + * @returns {number} Total episode count + */ + calculateTotalEpisodeCount(fileListEl) { + const mediaFiles = fileListEl.querySelectorAll('.file-item:not(.folder-item)'); + let totalEpisodeCount = 0; + + mediaFiles.forEach(item => { + const episodeEl = item.querySelector('.episode-number'); + if (episodeEl) { + const { start, end } = this.getEpisodeRange(episodeEl); + totalEpisodeCount += (end - start + 1); + } + }); + + return totalEpisodeCount; + } + + /** + * Get last episode end from file items + * @param {HTMLElement} fileListEl - File list element + * @returns {number} Last episode end number + */ + getLastEpisodeEnd(fileListEl) { + const mediaFiles = fileListEl.querySelectorAll('.file-item:not(.folder-item)'); + let lastEpisodeEnd = 0; + + mediaFiles.forEach((item, index) => { + const episodeEl = item.querySelector('.episode-number'); + if (episodeEl) { + const episodeEnd = parseInt(episodeEl.dataset.episodeEnd) || 1; + if (index === 0 || episodeEnd > lastEpisodeEnd) { + lastEpisodeEnd = episodeEnd; + } + } + }); + + return lastEpisodeEnd; + } +} + +module.exports = EpisodeManager; \ No newline at end of file diff --git a/utils/renderer/FileListManager.js b/utils/renderer/FileListManager.js new file mode 100644 index 0000000..80404b4 --- /dev/null +++ b/utils/renderer/FileListManager.js @@ -0,0 +1,243 @@ +/** + * FileListManager - Handles file list display and manipulation + */ +class FileListManager { + constructor(fileListEl) { + this.fileListEl = fileListEl; + this.draggedItem = null; + } + + /** + * Display files in the UI + * @param {Array} files - Array of file objects + */ + displayFiles(files) { + this.fileListEl.innerHTML = ''; + + if (files.length === 0) { + this.fileListEl.innerHTML = '

No media files found in this directory.

'; + return; + } + + // Separate folders from media files + const folders = files.filter(f => f.isFolder); + const mediaFiles = files.filter(f => !f.isFolder); + + // Track episode number for media files only + let episodeNumber = 1; + + // Display folders first (not draggable, no episode number) + folders.forEach(file => { + this._createFolderElement(file); + }); + + // Display media files with episode numbers and drag/drop + mediaFiles.forEach((file, index) => { + this._createMediaFileElement(file, episodeNumber, index); + episodeNumber++; + }); + } + + /** + * Create folder element + * @param {Object} file - File object + * @private + */ + _createFolderElement(file) { + const fileItem = document.createElement('div'); + fileItem.className = 'file-item folder-item'; + fileItem.innerHTML = ` +
📁
+
${file.name}
+
+
+
+
+ `; + + // Add click handler to navigate into folder + fileItem.addEventListener('click', () => { + // This will be handled by the caller + }); + + this.fileListEl.appendChild(fileItem); + } + + /** + * Create media file element + * @param {Object} file - File object + * @param {number} episodeNumber - Episode number + * @param {number} index - File index + */ + _createMediaFileElement(file, episodeNumber, index) { + const fileItem = document.createElement('div'); + fileItem.className = 'file-item'; + fileItem.draggable = true; + fileItem.dataset.index = index; + fileItem.dataset.filePath = file.path; + + // Use actual duration from file metadata + const duration = file.duration || '00:00'; + const quality = file.quality || 'unknown'; + const fps = file.fps || 'unknown'; + + // Add label for problematic files + const problemLabel = file.isProblematic ? '⚠️ ' : ''; + + fileItem.innerHTML = ` +
⋮⋮
+
+ +
${episodeNumber}
+ +
+
${file.name}
+
${duration}
+
${quality}
+
${fps}
+
+ 🏷️ + 💬 + 🗑️ + 🎬 + +
+ `; + + // Add drag and drop event listeners + fileItem.addEventListener('dragstart', this._handleDragStart.bind(this)); + fileItem.addEventListener('dragend', this._handleDragEnd.bind(this)); + fileItem.addEventListener('dragover', this._handleDragOver.bind(this)); + fileItem.addEventListener('dragleave', this._handleDragLeave.bind(this)); + fileItem.addEventListener('drop', this._handleDrop.bind(this)); + + this.fileListEl.appendChild(fileItem); + } + + /** + * Handle drag start + * @param {Event} e - Drag event + * @private + */ + _handleDragStart(e) { + this.draggedItem = this; + this.element.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', this.dataset.index); + } + + /** + * Handle drag end + * @param {Event} e - Drag event + * @private + */ + _handleDragEnd(e) { + this.element.classList.remove('dragging'); + // Remove drag-over class from all items + document.querySelectorAll('.file-item').forEach(item => { + item.classList.remove('drag-over'); + }); + this.draggedItem = null; + } + + /** + * Handle drag over + * @param {Event} e - Drag event + * @private + */ + _handleDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + // Only show drag-over for non-folder items + if (!this.classList.contains('folder-item') && this !== this.draggedItem) { + this.classList.add('drag-over'); + } + } + + /** + * Handle drag leave + * @param {Event} e - Drag event + * @private + */ + _handleDragLeave(e) { + this.classList.remove('drag-over'); + } + + /** + * Handle drop + * @param {Event} e - Drop event + */ + _handleDrop(e) { + e.preventDefault(); + this.classList.remove('drag-over'); + + if (this === this.draggedItem || this.classList.contains('folder-item')) return; + + // Get all media file items (not folders) + const fileItems = Array.from(this.fileListEl.querySelectorAll('.file-item:not(.folder-item)')); + const draggedIndex = fileItems.indexOf(this.draggedItem); + const dropIndex = fileItems.indexOf(this); + + if (draggedIndex === -1 || dropIndex === -1) return; + + // Move the dragged item in the DOM + if (draggedIndex < dropIndex) { + this.parentNode.insertBefore(this.draggedItem, this.nextSibling); + } else { + this.parentNode.insertBefore(this.draggedItem, this); + } + + // Update episode numbers + this._updateEpisodeNumbers(); + } + + /** + * Update episode numbers + */ + updateEpisodeNumbers() { + const fileItems = this.fileListEl.querySelectorAll('.file-item:not(.folder-item)'); + let episodeNum = 1; + + fileItems.forEach((item, index) => { + const episodeEl = item.querySelector('.episode-number'); + if (episodeEl) { + const storedStart = episodeEl.dataset.episodeStart; + const storedEnd = episodeEl.dataset.episodeEnd; + + const start = storedStart ? parseInt(storedStart) : episodeNum; + const end = storedEnd ? parseInt(storedEnd) : start; + + const rangeText = start === end ? `${start}` : `${start}-${end}`; + episodeEl.textContent = rangeText; + episodeEl.dataset.episode = episodeNum; + episodeNum = end + 1; + } + }); + } + + /** + * Clear the file list + */ + clear() { + this.fileListEl.innerHTML = ''; + } + + /** + * Get all media file items + * @returns {NodeList} Media file items + */ + getMediaFileItems() { + return this.fileListEl.querySelectorAll('.file-item:not(.folder-item)'); + } + + /** + * Get folder items + * @returns {NodeList} Folder items + */ + getFolderItems() { + return this.fileListEl.querySelectorAll('.folder-item'); + } +} + +module.exports = FileListManager; \ No newline at end of file diff --git a/utils/renderer/FileManager.js b/utils/renderer/FileManager.js new file mode 100644 index 0000000..acff237 --- /dev/null +++ b/utils/renderer/FileManager.js @@ -0,0 +1,195 @@ +const path = require('path'); +const { ipcRenderer } = require('electron'); + +/** + * FileManager - Handles file operations + */ +class FileManager { + constructor() { + } + + /** + * Select directory + * @returns {Promise} Selection result + */ + async selectDirectory() { + try { + // Use IPC to handle directory selection + const result = await ipcRenderer.invoke('select-directory'); + + if (result.success) { + return result; + } else { + throw new Error(result.error); + } + } catch (error) { + console.error('Error selecting directory:', error); + return { success: false, error: error.message }; + } + } + + /** + * Scan directory for media files + * @param {string} directoryPath - Directory path + * @returns {Promise} Scan result + */ + async scanDirectory(directoryPath) { + try { + const result = await ipcRenderer.invoke('scan-directory', directoryPath); + + if (result.success) { + return result; + } else { + throw new Error(result.error); + } + } catch (error) { + console.error('Error scanning directory:', error); + return { success: false, error: error.message }; + } + } + + /** + * Rename a file + * @param {string} oldPath - Old file path + * @param {string} newName - New file name + * @returns {Promise} Rename result + */ + async renameFile(oldPath, newName) { + try { + const result = await ipcRenderer.invoke('rename-file', { + oldPath: oldPath, + newName: newName + }); + + if (result.success) { + return result; + } else { + throw new Error(result.error); + } + } catch (error) { + console.error('Error renaming file:', error); + return { success: false, error: error.message }; + } + } + + /** + * Begin mapping files to Jellyfin naming convention + * @param {string} directory - Directory path + * @param {Array} files - Files to map + * @param {string|null} tvdbId - TVDB ID + * @returns {Promise} Mapping result + */ + async beginMapping(directory, files, tvdbId) { + try { + const result = await ipcRenderer.invoke('begin-mapping', { + directory: directory, + files: files, + tvdbId: tvdbId + }); + + if (result.success) { + return result; + } else { + throw new Error(result.error); + } + } catch (error) { + console.error('Mapping error:', error); + return { success: false, error: error.message }; + } + } + + /** + * Log audit event + * @param {string} directoryPath - Directory path + * @param {string} action - Action type + * @param {Object} details - Event details + * @returns {Promise} Audit result + */ + async logAuditEvent(directoryPath, action, details) { + try { + const result = await ipcRenderer.invoke('log-audit-event', { + directoryPath: directoryPath, + action: action, + details: details + }); + return result; + } catch (error) { + console.error('Failed to log audit event:', error); + return { success: false, error: error.message }; + } + } + + /** + * Move file to folder + * @param {string} filePath - File path + * @param {string} folderName - Folder name + * @returns {Promise} Move result + */ + async moveFileToFolder(filePath, folderName) { + try { + const result = await ipcRenderer.invoke('move-file-to-folder', { + filePath: filePath, + folderName: folderName + }); + return result; + } catch (error) { + console.error('Error moving file:', error); + return { success: false, error: error.message }; + } + } + + /** + * Log file info for debugging + * @param {string} filePath - File path + * @returns {Promise} File info result + */ + async logFileInfo(filePath) { + try { + const result = await ipcRenderer.invoke('log-file-info', filePath); + return result; + } catch (error) { + console.error('Error logging file info:', error); + return { success: false, error: error.message }; + } + } + + /** + * Open file in player + * @param {string} filePath - File path + */ + openFileInPlayer(filePath) { + ipcRenderer.invoke('open-file-in-player', filePath); + } + + /** + * Collect file data with episode ranges from UI order + * @param {HTMLElement} fileListEl - File list element + * @returns {Array} Array of file data objects + */ + collectFileData(fileListEl) { + const fileItems = fileListEl.querySelectorAll('.file-item:not(.folder-item)'); + const filesToMap = []; + + fileItems.forEach((item, index) => { + const fileNameEl = item.querySelector('.file-name'); + const qualityEl = item.querySelector('.file-quality'); + const episodeNumEl = item.querySelector('.episode-number'); + + if (fileNameEl && fileNameEl.dataset.filePath) { + const episodeStart = episodeNumEl ? parseInt(episodeNumEl.dataset.episodeStart || (index + 1)) : (index + 1); + const episodeEnd = episodeNumEl ? parseInt(episodeNumEl.dataset.episodeEnd || episodeStart) : episodeStart; + + filesToMap.push({ + filePath: fileNameEl.dataset.filePath, + quality: qualityEl ? qualityEl.textContent : '', + episodeStart: episodeStart, + episodeEnd: episodeEnd + }); + } + }); + + return filesToMap; + } +} + +module.exports = FileManager; \ No newline at end of file diff --git a/utils/renderer/ModalManager.js b/utils/renderer/ModalManager.js new file mode 100644 index 0000000..c6f6ec9 --- /dev/null +++ b/utils/renderer/ModalManager.js @@ -0,0 +1,211 @@ +const { ipcRenderer } = require('electron'); +const fs = require('fs'); + +/** + * ModalManager - Manages video preview modal + */ +class ModalManager { + constructor() { + this.videoPreviewModal = null; + } + + /** + * Open video preview modal + * @param {string} filePath - File path to preview + */ + async openVideoPreview(filePath) { + console.log('Opening video preview for:', filePath); + + // Create modal if it doesn't exist + if (!this.videoPreviewModal) { + this.createVideoPreviewModal(); + } + + // Show the modal + this.videoPreviewModal.style.display = 'block'; + + // Load video content + await this.loadVideoPreview(filePath); + } + + /** + * Create video preview modal + */ + createVideoPreviewModal() { + // Create modal container + this.videoPreviewModal = document.createElement('div'); + this.videoPreviewModal.id = 'video-preview-modal'; + this.videoPreviewModal.style.cssText = ` + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.9); + z-index: 1000; + justify-content: center; + align-items: center; + overflow: auto; + `; + + // Create modal content + const modalContent = document.createElement('div'); + modalContent.style.cssText = ` + position: relative; + max-width: 90%; + max-height: 90%; + background-color: #fff; + border-radius: 8px; + padding: 20px; + margin: 20px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + `; + + // Create close button + const closeButton = document.createElement('span'); + closeButton.innerHTML = '×'; + closeButton.style.cssText = ` + position: absolute; + top: 10px; + right: 15px; + font-size: 30px; + font-weight: bold; + color: #aaa; + cursor: pointer; + transition: color 0.3s; + `; + + closeButton.addEventListener('mouseenter', function() { + this.style.color = '#000'; + }); + + closeButton.addEventListener('click', function() { + this.videoPreviewModal.style.display = 'none'; + }.bind(this)); + + // Create video container + const videoContainer = document.createElement('div'); + videoContainer.id = 'video-preview-container'; + videoContainer.style.cssText = ` + text-align: center; + margin-bottom: 15px; + `; + + // Create file info display + const fileInfo = document.createElement('div'); + fileInfo.id = 'video-preview-file-info'; + fileInfo.style.cssText = ` + text-align: center; + margin-bottom: 15px; + padding: 10px; + background-color: #f8f9fa; + border-radius: 5px; + font-size: 14px; + `; + + // Create loading indicator + const loadingIndicator = document.createElement('div'); + loadingIndicator.id = 'video-preview-loading'; + loadingIndicator.textContent = 'Loading video preview...'; + loadingIndicator.style.cssText = ` + text-align: center; + padding: 20px; + font-size: 16px; + color: #666; + `; + + // Assemble modal + modalContent.appendChild(closeButton); + modalContent.appendChild(fileInfo); + modalContent.appendChild(videoContainer); + modalContent.appendChild(loadingIndicator); + this.videoPreviewModal.appendChild(modalContent); + + // Add click outside to close + this.videoPreviewModal.addEventListener('click', function(e) { + if (e.target === this.videoPreviewModal) { + this.videoPreviewModal.style.display = 'none'; + } + }.bind(this)); + + // Add to body + document.body.appendChild(this.videoPreviewModal); + } + + /** + * Load video preview + * @param {string} filePath - File path to load + */ + async loadVideoPreview(filePath) { + const fileInfo = document.getElementById('video-preview-file-info'); + const videoContainer = document.getElementById('video-preview-container'); + const loadingIndicator = document.getElementById('video-preview-loading'); + + // Show loading + loadingIndicator.style.display = 'block'; + videoContainer.innerHTML = ''; + fileInfo.innerHTML = `Loading: ${filePath.split('/').pop()}`; + + try { + // Check if file exists + if (!fs.existsSync(filePath)) { + throw new Error('File not found'); + } + + // Get file stats for info + const stats = fs.statSync(filePath); + const fileName = filePath.split('/').pop(); + + // Update file info + fileInfo.innerHTML = ` + File: ${fileName}
+ Size: ${(stats.size / (1024 * 1024)).toFixed(2)} MB
+ Path: ${filePath} + `; + + // For MKV files, open directly in default player + const ext = filePath.toLowerCase().split('.').pop(); + if (ext === 'mkv') { + // Close the modal and open in default player directly + this.videoPreviewModal.style.display = 'none'; + ipcRenderer.invoke('open-file-in-player', filePath); + } else { + // For other formats, try to create a video player + videoContainer.innerHTML = ` + + `; + + // Hide loading + loadingIndicator.style.display = 'none'; + } + } catch (error) { + console.error('Error loading video preview:', error); + fileInfo.innerHTML = `Error: Could not load preview for ${filePath}`; + videoContainer.innerHTML = '

Error loading video preview

'; + loadingIndicator.style.display = 'none'; + } + } + + /** + * Close the video preview modal + */ + closeVideoPreview() { + if (this.videoPreviewModal) { + this.videoPreviewModal.style.display = 'none'; + } + } + + /** + * Get the modal element + * @returns {HTMLElement|null} Modal element + */ + getModal() { + return this.videoPreviewModal; + } +} + +module.exports = ModalManager; \ No newline at end of file diff --git a/utils/renderer/ProgressManager.js b/utils/renderer/ProgressManager.js new file mode 100644 index 0000000..fb0da55 --- /dev/null +++ b/utils/renderer/ProgressManager.js @@ -0,0 +1,77 @@ +/** + * ProgressManager - Manages progress display and feedback + */ +class ProgressManager { + constructor(progressContainer, progressText, progressCount) { + this.progressContainer = progressContainer; + this.progressText = progressText; + this.progressCount = progressCount; + } + + /** + * Show progress indicator + * @param {string} message - Progress message + */ + showProgress(message = 'Scanning directory...') { + if (this.progressContainer) { + this.progressContainer.style.display = 'block'; + this.progressText.textContent = message; + this.progressCount.textContent = ''; + } + } + + /** + * Hide progress indicator + */ + hideProgress() { + if (this.progressContainer) { + this.progressContainer.style.display = 'none'; + } + } + + /** + * Update progress with current/total counts + * @param {number} current - Current count + * @param {number} total - Total count + * @param {string} fileName - Current file name + */ + updateProgress(current, total, fileName) { + if (this.progressContainer) { + const displayCurrent = current + 1; + this.progressText.textContent = `Processing: ${fileName}`; + this.progressCount.textContent = `${displayCurrent} of ${total} files`; + } + } + + /** + * Update progress with custom text + * @param {string} text - Progress text + */ + updateProgressText(text) { + if (this.progressText) { + this.progressText.textContent = text; + } + } + + /** + * Update progress count display + * @param {number} current - Current count + * @param {number} total - Total count + */ + updateProgressCount(current, total) { + if (this.progressCount) { + const displayCurrent = current + 1; + this.progressCount.textContent = `${displayCurrent} of ${total} files`; + } + } + + /** + * Show directory feedback + * @param {string} folderName - Folder name + */ + showDirectoryFeedback(folderName) { + console.log(`Directory "${folderName}" created and file moved`); + } +} + +module.exports = ProgressManager; \ No newline at end of file diff --git a/utils/renderer/SearchManager.js b/utils/renderer/SearchManager.js new file mode 100644 index 0000000..8c50aa1 --- /dev/null +++ b/utils/renderer/SearchManager.js @@ -0,0 +1,333 @@ +const { ipcRenderer } = require('electron'); + +/** + * SearchManager - Manages TVDB search and show selection + */ +class SearchManager { + constructor(searchInputEl, searchResultsEl) { + this.searchInputEl = searchInputEl; + this.searchResultsEl = searchResultsEl; + this.currentShow = null; + } + + /** + * Search shows by query + * @param {string} query - Search query + * @returns {Promise} Search results + */ + async searchShows(query) { + if (query.length < 2) { + this.clearSearchResults(); + return { success: true, results: [] }; + } + + try { + const result = await ipcRenderer.invoke('search-tvdb', query); + + if (result.success) { + this.displaySearchResults(result.results); + return result; + } else { + throw new Error(result.error); + } + } catch (error) { + console.error('Error searching shows:', error); + this.searchResultsEl.innerHTML = `

Error: ${error.message}

`; + return { success: false, error: error.message }; + } + } + + /** + * Display search results + * @param {Array} results - Search results array + */ + displaySearchResults(results) { + this.searchResultsEl.innerHTML = ''; + + if (results.length === 0) { + this.searchResultsEl.innerHTML = '

No shows found.

'; + return; + } + + results.forEach(show => { + const resultItem = document.createElement('div'); + resultItem.className = 'search-result-item'; + + // Create a more detailed display with separate elements + const showName = show.seriesName || show.name; + const showYear = show.firstAired ? show.firstAired.split('-')[0] : ''; + + resultItem.innerHTML = ` +
${showName}
+ ${showYear ? `
${showYear}
` : ''} + `; + + resultItem.addEventListener('click', () => this.selectShow(show)); + this.searchResultsEl.appendChild(resultItem); + }); + } + + /** + * Clear search results + */ + clearSearchResults() { + this.searchResultsEl.innerHTML = ''; + } + + /** + * Select a show + * @param {Object} show - Show object + * @param {Function} onShowTitleUpdate - Callback to update show title + * @param {Function} onSeasonsDisplay - Callback to display seasons + */ + async selectShow(show, onShowTitleUpdate, onSeasonsDisplay) { + console.log('Selected show:', show); + this.currentShow = show; + + // Clear search results when a show is selected + this.clearSearchResults(); + + // Display show title + if (onShowTitleUpdate) { + onShowTitleUpdate(show.seriesName || show.name); + } + + // Fetch show details including seasons + try { + const result = await ipcRenderer.invoke('get-show-details', show.id); + + if (result.success && result.data) { + const showData = result.data; + const seasons = showData.seasons || []; + + // Display seasons + if (onSeasonsDisplay) { + if (seasons.length === 0) { + onSeasonsDisplay('

Seasons will be loaded when you click on a season.

'); + } else { + onSeasonsDisplay(seasons); + } + } + } else { + console.error('Failed to fetch show details:', result.error); + if (onSeasonsDisplay) { + onSeasonsDisplay('

Error loading seasons: ' + (result.error || 'Unknown error') + '

'); + } + } + } catch (error) { + console.error('Error fetching show details:', error); + if (onSeasonsDisplay) { + onSeasonsDisplay('

Error loading seasons: ' + error.message + '

'); + } + } + } + + /** + * Display seasons + * @param {Array} seasons - Seasons array + * @param {Function} onEpisodeFetch - Callback to fetch episodes + * @param {Function} onBackToSeasons - Callback to go back to seasons + */ + displaySeasons(seasons, onEpisodeFetch, onBackToSeasons) { + const seasonsContainer = document.getElementById('seasons-container'); + seasonsContainer.innerHTML = ''; + + if (!seasons || seasons.length === 0) { + seasonsContainer.innerHTML = '

No seasons available.

'; + return; + } + + seasons.forEach(season => { + const seasonItem = document.createElement('div'); + seasonItem.className = 'season-item'; + + // Format season display with episode count + let seasonDisplay = `Season ${season.number}`; + if (season.type && season.type !== 'Unknown') { + seasonDisplay += ` (${season.type})`; + } + + const episodeCount = season.episodeCount || 0; + + seasonItem.innerHTML = ` +
+ ${seasonDisplay} + + ${episodeCount} eps + +
+ `; + + // Add click event to fetch episodes + seasonItem.addEventListener('click', () => { + // Remove selected class from all seasons + document.querySelectorAll('.season-item').forEach(item => item.classList.remove('selected')); + // Add selected class to clicked season + seasonItem.classList.add('selected'); + if (onEpisodeFetch) { + onEpisodeFetch(season.number); + } + }); + + seasonsContainer.appendChild(seasonItem); + }); + } + + /** + * Fetch and display episodes + * @param {string} showId - Show ID + * @param {number} seasonNumber - Season number + * @param {Function} onEpisodesDisplay - Callback to display episodes + */ + async fetchAndDisplayEpisodes(showId, seasonNumber, onEpisodesDisplay) { + try { + // Show loading message while fetching + const seasonsContainer = document.getElementById('seasons-container'); + seasonsContainer.innerHTML = '

Loading episodes...

'; + + const result = await ipcRenderer.invoke('get-season-episodes', showId, seasonNumber); + + if (result.success && result.data) { + const episodes = result.data.episodes || []; + if (onEpisodesDisplay) { + onEpisodesDisplay(episodes); + } + } else { + console.error('Failed to fetch episodes:', result.error); + if (onEpisodesDisplay) { + onEpisodesDisplay(null, 'Error loading episodes: ' + (result.error || 'Unknown error')); + } + } + } catch (error) { + console.error('Error fetching episodes:', error); + // Show a more user-friendly error message + if (onEpisodesDisplay) { + onEpisodesDisplay(null, `Error loading episodes: ${error.message || 'Failed to load episodes'}`); + } + } + } + + /** + * Display episodes + * @param {Array} episodes - Episodes array + * @param {number} episodeCount - Episode count + * @param {Function} onBackToSeasons - Callback to go back to seasons + * @param {Function} onFileListUpdate - Callback to update file list + */ + displayEpisodes(episodes, episodeCount, onBackToSeasons, onFileListUpdate) { + const seasonsContainer = document.getElementById('seasons-container'); + seasonsContainer.innerHTML = ''; + + if (!episodes || episodes.length === 0) { + seasonsContainer.innerHTML = '

No episodes available.

'; + if (onBackToSeasons) onBackToSeasons(0); + return; + } + + if (onBackToSeasons) { + onBackToSeasons(episodes.length); + } + + // Create a back button to return to seasons + const backButton = document.createElement('div'); + backButton.className = 'season-item'; + backButton.innerHTML = '← Back to Seasons'; + backButton.addEventListener('click', () => { + if (onBackToSeasons) onBackToSeasons(0); + if (onEpisodesDisplay) { + // Re-display seasons + } + }); + seasonsContainer.appendChild(backButton); + + // Create episodes container + const episodesContainer = document.createElement('div'); + episodesContainer.id = 'episodes-container'; + + episodes.forEach(episode => { + const episodeRow = document.createElement('div'); + episodeRow.className = 'episode-item'; + + // Format episode display + const episodeName = episode.name || 'Untitled'; + const episodeRuntime = episode.runtime ? `${episode.runtime}m` : ''; + + episodeRow.innerHTML = ` +
+ E${episode.number} ${episodeName} + ${episodeRuntime ? `${episodeRuntime}` : ''} +
+ `; + + episodesContainer.appendChild(episodeRow); + }); + + seasonsContainer.appendChild(episodesContainer); + + // Also update the main file list to show episode matching information + if (onFileListUpdate) { + onFileListUpdate(episodes); + } + } + + /** + * Update file list with episode info + * @param {Array} episodes - Episodes array + * @param {Array} currentFiles - Current files array + */ + updateFileListWithEpisodeInfo(episodes, currentFiles) { + // When we have episodes, we want to show basic episode information + if (currentFiles && currentFiles.length > 0 && episodes && episodes.length > 0) { + console.log('Updating file list with episode info for', episodes.length, 'episodes'); + + // Create a simple note about episode availability + const fileListContainer = document.getElementById('file-list'); + + // Add a simple note about episodes + const episodeNote = document.createElement('div'); + episodeNote.style.marginTop = '15px'; + episodeNote.style.padding = '10px'; + episodeNote.style.backgroundColor = '#e7f3ff'; + episodeNote.style.border = '1px solid #b3d9ff'; + episodeNote.style.borderRadius = '5px'; + episodeNote.style.fontSize = '14px'; + episodeNote.innerHTML = ` + Episode Information: + ${episodes.length} episodes available for this show. +
Episode details are displayed in the sidebar. + `; + + // Add this note to the file list container + if (fileListContainer.firstChild) { + fileListContainer.insertBefore(episodeNote, fileListContainer.firstChild); + } else { + fileListContainer.appendChild(episodeNote); + } + } + } + + /** + * Test TVDB API connectivity + * @returns {Promise} Test result + */ + async testTVDBAPI() { + try { + const result = await ipcRenderer.invoke('test-tvdb-api'); + console.log('TVDB API Test:', result); + if (result.success) { + console.log('TVDB API is working:', result.message); + alert('TVDB API Test Successful!\n' + result.message); + } else { + console.error('TVDB API test failed:', result.error); + alert('TVDB API Test Failed!\n' + result.error); + } + return result; + } catch (error) { + console.error('Error testing TVDB API:', error); + alert('Error testing TVDB API: ' + error.message); + return { success: false, error: error.message }; + } + } +} + +module.exports = SearchManager; \ No newline at end of file diff --git a/utils/renderer/TagManager.js b/utils/renderer/TagManager.js new file mode 100644 index 0000000..65c9a09 --- /dev/null +++ b/utils/renderer/TagManager.js @@ -0,0 +1,257 @@ +const { ipcRenderer } = require('electron'); + +/** + * TagManager - Manages file tagging functionality + */ +class TagManager { + constructor() { + this.tagColors = { + extra: '#FFD700', // Yellow + commentary: '#17a2b8', // Teal + delete: '#dc3545' // Red + }; + } + + /** + * Tag a file with a specific tag type + * @param {string} filePath - File path + * @param {string} tagType - Tag type (extra, commentary, delete) + * @param {Function} callback - Callback function + */ + tagFile(filePath, tagType, callback) { + console.log(`Tagging file ${filePath} as ${tagType}`); + + // Find the file item in the UI + const fileItems = document.querySelectorAll('.file-item'); + fileItems.forEach(item => { + const fileNameElement = item.querySelector('.file-name'); + if (fileNameElement && fileNameElement.dataset.filePath === filePath) { + this._applyTagVisuals(item, tagType); + item.setAttribute('data-tagged-' + tagType, 'true'); + this._enablePlayButton(item); + } + }); + + // Update the tagged count display + if (callback && typeof callback === 'function') { + callback(); + } + + console.log(`File ${filePath} tagged as ${tagType}`); + } + + /** + * Apply visual styling for a tag + * @param {HTMLElement} fileItem - File item element + * @param {string} tagType - Tag type + * @private + */ + _applyTagVisuals(fileItem, tagType) { + const tagIcon = fileItem.querySelector(`.${tagType}-tag`); + if (tagIcon) { + const tagColor = this.tagColors[tagType]; + + // Make the icon fully saturated and highlight + tagIcon.style.opacity = '1'; + tagIcon.style.filter = 'none'; + tagIcon.style.color = tagColor; + tagIcon.style.textShadow = `0 0 15px ${tagColor}`; + tagIcon.style.transform = 'scale(1.3)'; + } + } + + /** + * Enable play button for a file + * @param {HTMLElement} fileItem - File item element + * @private + */ + _enablePlayButton(fileItem) { + const playButton = fileItem.querySelector('.play-button'); + if (playButton) { + playButton.style.opacity = '1'; + playButton.style.cursor = 'pointer'; + playButton.disabled = false; + playButton.style.pointerEvents = 'auto'; + } + } + + /** + * Untag a file + * @param {string} filePath - File path + * @param {string} tagType - Tag type + */ + untagFile(filePath, tagType) { + console.log(`Untagging file ${filePath} from ${tagType}`); + + // Find the file item in the UI + const fileItems = document.querySelectorAll('.file-item'); + fileItems.forEach(item => { + const fileNameElement = item.querySelector('.file-name'); + if (fileNameElement && fileNameElement.dataset.filePath === filePath) { + this._removeTagVisuals(item, tagType); + item.removeAttribute('data-tagged-' + tagType); + this._checkAndDisablePlayButton(item); + } + }); + } + + /** + * Remove tag visuals + * @param {HTMLElement} fileItem - File item element + * @param {string} tagType - Tag type + * @private + */ + _removeTagVisuals(fileItem, tagType) { + const tagIcon = fileItem.querySelector(`.${tagType}-tag`); + if (tagIcon) { + // Reset to original appearance + tagIcon.style.opacity = '0.7'; + tagIcon.style.filter = 'none'; + tagIcon.style.color = ''; + tagIcon.style.textShadow = 'none'; + tagIcon.style.transform = 'scale(1)'; + tagIcon.style.boxShadow = 'none'; + } + } + + /** + * Check and disable play button if no tags remain + * @param {HTMLElement} fileItem - File item element + * @private + */ + _checkAndDisablePlayButton(fileItem) { + const hasOtherTags = fileItem.hasAttribute('data-tagged-extra') || + fileItem.hasAttribute('data-tagged-commentary') || + fileItem.hasAttribute('data-tagged-delete'); + + if (!hasOtherTags) { + const playButton = fileItem.querySelector('.play-button'); + if (playButton) { + playButton.style.opacity = '0.3'; + playButton.style.cursor = 'default'; + playButton.disabled = true; + playButton.style.pointerEvents = 'none'; + } + } + } + + /** + * Move a tagged file + * @param {string} filePath - File path + * @param {string} tagType - Tag type + * @returns {Promise} Move result + */ + async moveTaggedFile(filePath, tagType) { + console.log(`Moving file ${filePath} to ${tagType} folder`); + + // Send request to main process to move the file + const result = await ipcRenderer.invoke('move-file-to-folder', { + filePath: filePath, + folderName: tagType + }); + + if (result.success) { + console.log(`File moved successfully to ${tagType} folder`); + return { success: true, filePath }; + } else { + console.error(`Failed to move file: ${result.error}`); + return { success: false, error: result.error, filePath }; + } + } + + /** + * Move all tagged files + * @param {Function} onUpdateCount - Callback to update count + * @param {Function} onEpisodesUpdated - Callback to update episodes + * @param {Function} onMatchCheck - Callback to check episode match + * @returns {Promise} Results summary + */ + async moveAllTaggedFiles(onUpdateCount, onEpisodesUpdated, onMatchCheck) { + const taggedItems = document.querySelectorAll('.file-item[data-tagged-extra], .file-item[data-tagged-commentary], .file-item[data-tagged-delete]'); + + if (taggedItems.length === 0) { + console.log('No tagged files to move'); + return { success: true, successful: 0, failed: 0 }; + } + + console.log(`Moving ${taggedItems.length} tagged files`); + + // Visual feedback - change circle color while processing + const taggedCircle = document.getElementById('tagged-circle'); + if (taggedCircle) { + taggedCircle.style.backgroundColor = '#ffc107'; + taggedCircle.style.pointerEvents = 'none'; + } + + const results = []; + for (const item of taggedItems) { + const filePath = item.querySelector('.file-name').dataset.filePath; + let tagType; + if (item.hasAttribute('data-tagged-extra')) { + tagType = 'extra'; + } else if (item.hasAttribute('data-tagged-commentary')) { + tagType = 'commentary'; + } else if (item.hasAttribute('data-tagged-delete')) { + tagType = 'delete'; + } + + const result = await this.moveTaggedFile(filePath, tagType); + results.push(result); + + if (result.success) { + // Remove the item from the file list after successful move + item.remove(); + } + } + + // Reset circle appearance + if (taggedCircle) { + taggedCircle.style.backgroundColor = ''; + taggedCircle.style.pointerEvents = ''; + } + + // Update the tagged count after all moves + if (onUpdateCount) onUpdateCount(); + if (onEpisodesUpdated) onEpisodesUpdated(); + if (onMatchCheck) onMatchCheck(); + + // Summary of results + const successful = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success).length; + + if (failed > 0) { + alert(`Moved ${successful} files. ${failed} files failed to move.`); + } else if (successful > 0) { + console.log(`Successfully moved all ${successful} files`); + } + + return { success: true, successful, failed }; + } + + /** + * Get all tagged files + * @returns {Array} Array of tagged file objects + */ + getTaggedFiles() { + const taggedItems = document.querySelectorAll('.file-item[data-tagged-extra], .file-item[data-tagged-commentary], .file-item[data-tagged-delete]'); + const taggedFiles = []; + + taggedItems.forEach(item => { + const filePath = item.querySelector('.file-name').dataset.filePath; + let tagType; + if (item.hasAttribute('data-tagged-extra')) { + tagType = 'extra'; + } else if (item.hasAttribute('data-tagged-commentary')) { + tagType = 'commentary'; + } else if (item.hasAttribute('data-tagged-delete')) { + tagType = 'delete'; + } + + taggedFiles.push({ filePath, tagType }); + }); + + return taggedFiles; + } +} + +module.exports = TagManager; \ No newline at end of file diff --git a/utils/renderer/UIManager.js b/utils/renderer/UIManager.js new file mode 100644 index 0000000..33b15bb --- /dev/null +++ b/utils/renderer/UIManager.js @@ -0,0 +1,594 @@ +const { ipcRenderer } = require('electron'); +const AppState = require('./AppState'); +const FileListManager = require('./FileListManager'); +const TagManager = require('./TagManager'); +const EpisodeManager = require('./EpisodeManager'); +const SearchManager = require('./SearchManager'); +const FileManager = require('./FileManager'); +const ModalManager = require('./ModalManager'); +const ProgressManager = require('./ProgressManager'); + +/** + * UIManager - Main coordinator for UI functionality + */ +class UIManager { + constructor() { + // Initialize managers + this.appState = new AppState(); + this.fileManager = new FileManager(); + this.modalManager = new ModalManager(); + + // Get DOM elements + this.selectDirBtn = document.getElementById('select-dir-btn'); + this.selectedDirEl = document.getElementById('selected-dir'); + this.searchInput = document.getElementById('search-input'); + this.searchResultsEl = document.getElementById('search-results'); + this.fileListEl = document.getElementById('file-list'); + this.showDetailsEl = document.getElementById('show-details'); + this.progressContainer = document.getElementById('progress-container'); + this.progressText = document.getElementById('progress-text'); + this.progressCount = document.getElementById('progress-count'); + + // Initialize UI managers + this.fileListManager = new FileListManager(this.fileListEl); + this.tagManager = new TagManager(); + this.episodeManager = new EpisodeManager(); + this.searchManager = new SearchManager(this.searchInput, this.searchResultsEl); + this.progressManager = new ProgressManager( + this.progressContainer, + this.progressText, + this.progressCount + ); + + // Set up event listeners + this._setupEventListeners(); + } + + /** + * Set up all event listeners + * @private + */ + _setupEventListeners() { + // Select directory button + this.selectDirBtn.addEventListener('click', () => this._handleSelectDirectory()); + + // Search input with debounce + this.searchInput.addEventListener('input', this._debounce(() => this._handleSearch(), 300)); + + // Episode number editing + document.addEventListener('click', (e) => { + if (e.target.classList.contains('episode-number')) { + e.stopPropagation(); + this.episodeManager.makeEpisodeRangeEditable(e.target); + } + }); + + // Begin mapping button + const beginMappingBtn = document.getElementById('begin-mapping-btn'); + if (beginMappingBtn) { + beginMappingBtn.addEventListener('click', () => this._handleBeginMapping()); + } + + // Floating circle (play button) to move all tagged files + const taggedCircle = document.getElementById('tagged-circle'); + if (taggedCircle) { + taggedCircle.addEventListener('click', () => this._handleMoveAllTaggedFiles()); + } + + // Listen for scan progress updates from main process + ipcRenderer.on('scan-progress', (event, { current, total, fileName }) => { + this.progressManager.updateProgress(current, total, fileName); + }); + + // Listen for auto-select directory message + window.addEventListener('message', (event) => { + if (event.data.type === 'auto-select-directory') { + this._handleAutoSelectDirectory(event.data.directory); + } + }); + + // Listen for auto-select directory from main process + ipcRenderer.on('auto-select-directory', (event, directory) => { + this._handleAutoSelectDirectory(directory); + }); + + // Initialize tagged count on page load + this._updateTaggedCount(); + } + + /** + * Handle select directory + * @private + */ + async _handleSelectDirectory() { + try { + const result = await this.fileManager.selectDirectory(); + + if (result.success) { + const directory = result.directory; + this.appState.setCurrentDirectory(directory); + this.selectedDirEl.textContent = `Selected: ${directory}`; + + // Log audit event for directory selection + await this._logAuditEvent('select_directory', { directory: directory }); + + // Scan the directory for media files + await this._scanDirectory(directory); + } else { + throw new Error(result.error); + } + } catch (error) { + console.error('Error selecting directory:', error); + alert(`Error: ${error.message}`); + } + } + + /** + * Handle auto-select directory + * @param {string} directory - Directory path + * @private + */ + async _handleAutoSelectDirectory(directory) { + try { + console.log('Auto-selecting directory:', directory); + + this.appState.setCurrentDirectory(directory); + this.selectedDirEl.textContent = `Selected: ${directory}`; + + // Log audit event for directory selection + await this._logAuditEvent('select_directory', { directory: directory }); + + // Scan the directory for media files + await this._scanDirectory(directory); + } catch (error) { + console.error('Error auto-selecting directory:', error); + alert(`Error: ${error.message}`); + } + } + + /** + * Open a specific directory + * @param {string} directory - Directory path + */ + async openDirectory(directory) { + try { + console.log('Opening directory:', directory); + + // Set the current directory + this.appState.setCurrentDirectory(directory); + this.selectedDirEl.textContent = `Selected: ${directory}`; + + // Log audit event for directory selection + await this._logAuditEvent('select_directory', { directory: directory }); + + // Scan the directory for media files + await this._scanDirectory(directory); + } catch (error) { + console.error('Error opening directory:', error); + alert(`Error: ${error.message}`); + } + } + + /** + * Handle search + * @private + */ + async _handleSearch() { + const query = this.searchInput.value.trim(); + await this.searchManager.searchShows(query); + } + + /** + * Scan directory + * @param {string} directoryPath - Directory path + * @private + */ + async _scanDirectory(directoryPath) { + try { + // Show progress bar + this.progressManager.showProgress(); + + const result = await this.fileManager.scanDirectory(directoryPath); + + // Hide progress bar + this.progressManager.hideProgress(); + + if (result.success) { + this.appState.setCurrentFiles(result.files); + this.fileListManager.displayFiles(result.files); + } else { + throw new Error(result.error); + } + } catch (error) { + this.progressManager.hideProgress(); + console.error('Error scanning directory:', error); + alert(`Error scanning directory: ${error.message}`); + } + } + + /** + * Handle begin mapping + * @private + */ + async _handleBeginMapping() { + const btn = document.getElementById('begin-mapping-btn'); + + // Get all media file items (not folders) + const fileItems = this.fileListManager.getMediaFileItems(); + + if (fileItems.length === 0) { + alert('No media files to map. Please select a directory first.'); + return; + } + + if (!this.appState.getCurrentDirectory()) { + alert('No directory selected.'); + return; + } + + // Disable button during processing + btn.disabled = true; + btn.textContent = 'Mapping...'; + + try { + // Collect file data with episode ranges from UI order + const filesToMap = this.fileManager.collectFileData(this.fileListEl); + + // Get TVDB ID from current show if selected + const tvdbId = this.appState.getCurrentShow() ? this.appState.getCurrentShow().id : null; + + const result = await this.fileManager.beginMapping( + this.appState.getCurrentDirectory(), + filesToMap, + tvdbId + ); + + if (result.success) { + alert('Mapping Complete!'); + // Update currentDirectory if it changed (show folder was renamed) + const newDir = result.newDirectory || this.appState.getCurrentDirectory(); + this.appState.setCurrentDirectory(newDir); + // Refresh the file list to show new names + await this.openDirectory(newDir); + } else { + alert('Mapping failed: ' + (result.error || 'Unknown error')); + } + } catch (error) { + console.error('Mapping error:', error); + alert('Mapping failed: ' + error.message); + } finally { + btn.disabled = false; + btn.textContent = 'Begin Mapping'; + } + } + + /** + * Handle move all tagged files + * @private + */ + async _handleMoveAllTaggedFiles() { + await this.tagManager.moveAllTaggedFiles( + () => this._updateTaggedCount(), + () => this.fileListManager.updateEpisodeNumbers(), + () => this._checkEpisodeCountMatch() + ); + } + + /** + * Update tagged count + * @private + */ + _updateTaggedCount() { + const taggedItems = document.querySelectorAll('.file-item[data-tagged-extra], .file-item[data-tagged-commentary], .file-item[data-tagged-delete]'); + const count = taggedItems.length; + const taggedCircle = document.getElementById('tagged-circle'); + const taggedCount = document.getElementById('tagged-count'); + + if (taggedCount) { + taggedCount.textContent = count; + } + + // Animate the circle position based on count + if (taggedCircle) { + if (count === 0) { + // Move down out of sight + taggedCircle.style.transform = 'translateY(100px)'; + taggedCircle.style.bottom = '-100px'; + } else { + // Move up to show count (appears at the same spot at bottom right) + taggedCircle.style.transform = 'translateY(0)'; + taggedCircle.style.bottom = '20px'; + } + } + } + + /** + * Check episode count match + * @private + */ + _checkEpisodeCountMatch() { + // Count only actual media files (not folders) + const mediaFiles = this.fileListManager.getMediaFileItems(); + + // Calculate total episode range (sum of all episode ranges) + let totalEpisodeCount = 0; + let lastEpisodeEnd = 0; + + mediaFiles.forEach((item, index) => { + const episodeEl = item.querySelector('.episode-number'); + if (episodeEl) { + const episodeStart = parseInt(episodeEl.dataset.episodeStart || (index + 1)); + const episodeEnd = parseInt(episodeEl.dataset.episodeEnd || episodeStart); + + // Calculate episodes in this range + const rangeSize = episodeEnd - episodeStart + 1; + totalEpisodeCount += rangeSize; + + // Track the last episode end for sequential checking + if (index === 0 || episodeEnd > lastEpisodeEnd) { + lastEpisodeEnd = episodeEnd; + } + } + }); + + // Use lastEpisodeEnd for comparison (handles ranges properly) + const selectedSeasonEpisodeCount = this.appState.getSelectedSeasonEpisodeCount(); + if (selectedSeasonEpisodeCount > 0 && lastEpisodeEnd === selectedSeasonEpisodeCount) { + // Perfect match - add green outline + this.fileListEl.style.border = '3px solid #28a745'; + this.fileListEl.style.boxShadow = '0 0 15px rgba(40, 167, 69, 0.4)'; + } else { + // No match or no season selected - remove green outline + this.fileListEl.style.border = ''; + this.fileListEl.style.boxShadow = ''; + } + } + + /** + * Log audit event + * @param {string} action - Action type + * @param {Object} details - Event details + * @private + */ + async _logAuditEvent(action, details) { + const currentDirectory = this.appState.getCurrentDirectory(); + if (currentDirectory) { + await this.fileManager.logAuditEvent(currentDirectory, action, details); + } + } + + /** + * Debounce function + * @param {Function} func - Function to debounce + * @param {number} wait - Wait time in ms + * @returns {Function} Debounced function + * @private + */ + _debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + /** + * Display created folder + * @param {string} folderName - Folder name + */ + displayCreatedFolder(folderName) { + // Check if folder entry already exists + const existingFolder = document.querySelector(`.folder-item[data-folder-name="${folderName}"]`); + if (existingFolder) { + // Update the file count + const countEl = existingFolder.querySelector('.folder-count'); + if (countEl) { + const currentCount = parseInt(countEl.textContent) || 0; + countEl.textContent = currentCount + 1; + } + return; + } + + // Create folder item element + const folderItem = document.createElement('div'); + folderItem.className = 'folder-item'; + folderItem.setAttribute('data-folder-name', folderName); + + let displayName, folderIcon, folderColor; + if (folderName === 'extra') { + displayName = 'extras'; + folderIcon = '📁'; + folderColor = '#FFD700'; + } else if (folderName === 'commentary') { + displayName = 'commentary'; + folderIcon = '💬'; + folderColor = '#17a2b8'; + } else if (folderName === 'delete') { + displayName = 'delete'; + folderIcon = '🗑️'; + folderColor = '#dc3545'; + } + + folderItem.innerHTML = ` +
${folderIcon}
+
${displayName}
+
1
+ `; + + folderItem.style.cssText = ` + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + margin-bottom: 8px; + background-color: #16213e; + border: 2px solid ${folderColor}; + border-radius: 8px; + cursor: pointer; + `; + + // Insert at the top of the file list + if (this.fileListEl.firstChild) { + this.fileListEl.insertBefore(folderItem, this.fileListEl.firstChild); + } else { + this.fileListEl.appendChild(folderItem); + } + + console.log(`Folder "${displayName}" displayed in UI`); + } + + /** + * Make file name editable + * @param {HTMLElement} element - File name element + */ + async makeEditable(element) { + // Prevent editing if already in edit mode + if (element.contentEditable === 'true') return; + + // Store original text + const originalText = element.textContent; + + // Make element editable + element.contentEditable = 'true'; + element.focus(); + element.classList.add('editing'); + + // Select all text when editing starts + const range = document.createRange(); + range.selectNodeContents(element); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + // Handle saving when user finishes editing + const saveEdit = async () => { + if (element.textContent.trim() !== originalText.trim()) { + // Send request to main process to rename the file + try { + const result = await this.fileManager.renameFile( + element.dataset.filePath, + element.textContent.trim() + ); + + if (result.success) { + console.log('File renamed successfully:', result.message); + // Update the file name in the UI to reflect the change + element.textContent = element.textContent.trim(); + + // Also update the data attribute to reflect the new path + const oldPath = element.dataset.filePath; + const newPath = oldPath.substring(0, oldPath.lastIndexOf(path.sep) + 1) + element.textContent.trim(); + element.dataset.filePath = newPath; + } else { + console.error('Failed to rename file:', result.error); + // Revert to original name on failure + element.textContent = originalText; + } + } catch (error) { + console.error('Error renaming file:', error); + // Revert to original name on error + element.textContent = originalText; + } + } + + // Clean up + element.contentEditable = 'false'; + element.classList.remove('editing'); + }; + + // Save on Enter key or blur + element.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + saveEdit(); + } + }); + + element.addEventListener('blur', saveEdit); + } + + /** + * Select show and display details + * @param {Object} show - Show object + */ + async selectShow(show) { + await this.searchManager.selectShow( + show, + (title) => { + document.getElementById('show-title').textContent = title; + }, + (seasonsOrHtml) => { + if (typeof seasonsOrHtml === 'string') { + document.getElementById('seasons-container').innerHTML = seasonsOrHtml; + } else { + this.searchManager.displaySeasons(seasonsOrHtml, + (seasonNumber) => this._handleSeasonSelect(show.id, seasonNumber), + () => this._handleBackToSeasons() + ); + } + } + ); + } + + /** + * Handle season select + * @param {string} showId - Show ID + * @param {number} seasonNumber - Season number + */ + async _handleSeasonSelect(showId, seasonNumber) { + await this.searchManager.fetchAndDisplayEpisodes( + showId, + seasonNumber, + (episodes, error) => { + if (error) { + document.getElementById('seasons-container').innerHTML = ` +
+

Error loading episodes: ${error}

+

Note: This may be due to API limitations with the TVDB v4 API.

+
+ `; + } else { + this.searchManager.displayEpisodes( + episodes, + episodes.length, + (count) => this.appState.setSelectedSeasonEpisodeCount(count), + (episodes) => this.searchManager.updateFileListWithEpisodeInfo(episodes, this.appState.getCurrentFiles()) + ); + this._checkEpisodeCountMatch(); + } + } + ); + } + + /** + * Handle back to seasons + */ + _handleBackToSeasons() { + this.appState.setSelectedSeasonEpisodeCount(0); + this._checkEpisodeCountMatch(); + // Re-display seasons would happen here + } + + /** + * Debug problematic file + * @param {string} filePath - File path + */ + async debugProblematicFile(filePath) { + try { + const result = await this.fileManager.logFileInfo(filePath); + if (result.success) { + console.log('File info:', result.fileInfo); + } else { + console.error('Failed to get file info:', result.error); + } + } catch (error) { + console.error('Error debugging file:', error); + } + } +} + +module.exports = UIManager; \ No newline at end of file