MovieMapper/renderer.js
Jarian Cottingham 52678d3c47 Fix episode number arrow cascade and remove Update Ranges button
- Add isUpdatingEpisodeNumbers flag to prevent re-entrant calls
- Arrow buttons now properly cascade shifts to all subsequent episodes
- Remove Update Ranges button and associated code
- Remove DevTools auto-open for cleaner release
- Syntax validation passes
2026-02-25 00:24:54 -06:00

1609 lines
53 KiB
JavaScript

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) {
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}`);
}
}
// 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 = '<p>No media files found in this directory.</p>';
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 = `
<div style="font-size: 18px; margin-right: 10px;">📁</div>
<div class="file-name folder-name" data-file-path="${file.path}">${file.name}</div>
<div class="file-duration"></div>
<div class="file-quality"></div>
<div class="file-fps"></div>
<div class="file-tags"></div>
`;
// 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 ? '<span class="problematic-label">⚠️</span> ' : '';
fileItem.innerHTML = `
<div class="drag-handle">⋮⋮</div>
<div class="episode-number-container">
<button class="episode-arrow episode-arrow-left" data-file-path="${file.path}" title="Decrease episode range">◀</button>
<div class="episode-number" data-episode-start="${episodeNumber}" data-episode-end="${episodeNumber}">${episodeNumber}</div>
<button class="episode-arrow episode-arrow-right" data-file-path="${file.path}" title="Increase episode range">▶</button>
</div>
<div class="file-name" data-file-path="${file.path}">${file.name}</div>
<div class="file-duration">${duration}</div>
<div class="file-quality">${quality}</div>
<div class="file-fps">${fps}</div>
<div class="file-tags">
<span class="tag-icon extra-tag" data-file-path="${file.path}" title="Mark as Extra">🏷️</span>
<span class="tag-icon commentary-tag" data-file-path="${file.path}" title="Add Commentary">💬</span>
<span class="tag-icon delete-tag" data-file-path="${file.path}" title="Mark for Deletion">🗑️</span>
<span class="video-preview-btn" data-file-path="${file.path}" title="Preview Video">🎬</span>
<button class="play-button" style="opacity: 0.3; cursor: default; flex-shrink: 0;" data-file-path="${file.path}" disabled>▶️</button>
</div>
`;
// 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 = `
<div class="folder-icon" style="font-size: 24px;">${folderIcon}</div>
<div class="folder-name" style="font-weight: bold; color: ${folderColor};">${displayName}</div>
<div class="folder-count" style="background-color: ${folderColor}; color: white; border-radius: 12px; padding: 2px 8px; font-size: 12px;">1</div>
`;
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 = `<p>Error: ${error.message}</p>`;
}
}
// 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 = '<p style="color: #888; text-align: center; padding: 10px;">No shows found.</p>';
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 = `
<div class="show-name">${showName}</div>
${showYear ? `<div class="show-year">${showYear}</div>` : ''}
`;
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 = '<p>Seasons will be loaded when you click on a season.</p>';
} else {
displaySeasons(currentSeasons);
}
} else {
console.error('Failed to fetch show details:', result.error);
document.getElementById('seasons-container').innerHTML = '<p>Error loading seasons: ' + (result.error || 'Unknown error') + '</p>';
}
} catch (error) {
console.error('Error fetching show details:', error);
document.getElementById('seasons-container').innerHTML = '<p>Error loading seasons: ' + error.message + '</p>';
}
}
// Display seasons with episode counts
function displaySeasons(seasons) {
const seasonsContainer = document.getElementById('seasons-container');
seasonsContainer.innerHTML = '';
if (!seasons || seasons.length === 0) {
seasonsContainer.innerHTML = '<p style="color: #888;">No seasons available.</p>';
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 = `
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>${seasonDisplay}</span>
<span style="background-color: #e94560; color: white; padding: 4px 8px; border-radius: 12px; font-size: 11px;">
${episodeCount} eps
</span>
</div>
`;
// 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 = '<p>Loading episodes...</p>';
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 = '<p>Error loading episodes: ' + (result.error || 'Unknown error') + '</p>';
}
} catch (error) {
console.error('Error fetching episodes:', error);
// Show a more user-friendly error message
document.getElementById('seasons-container').innerHTML = `
<div style="padding: 10px; background-color: #f8d7da; color: #721c24; border-radius: 4px;">
<p>Error loading episodes: ${error.message || 'Failed to load episodes'}</p>
<p style="font-size: 12px; margin-top: 5px;">Note: This may be due to API limitations with the TVDB v4 API.</p>
</div>
`;
}
}
// Display episodes in clean table format
function displayEpisodes(episodes) {
const seasonsContainer = document.getElementById('seasons-container');
seasonsContainer.innerHTML = '';
if (!episodes || episodes.length === 0) {
seasonsContainer.innerHTML = '<p style="color: #888;">No episodes available.</p>';
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 = `
<div style="display: flex; justify-content: space-between; align-items: center;">
<span><strong>E${episode.number}</strong> ${episodeName}</span>
${episodeRuntime ? `<span style="color: #888; font-size: 11px;">${episodeRuntime}</span>` : ''}
</div>
`;
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 = `
<h4 style="margin-top: 0; color: #ff9800;">File-Episode Relationship</h4>
<p style="font-size: 14px; margin: 5px 0;">
<strong>Matching Strategy:</strong> Files are matched to episodes based on naming patterns.
</p>
<p style="font-size: 14px; margin: 5px 0;">
<strong>Example:</strong> "Show.S01E01.Title.mp4" matches Episode 1 of Season 1.
</p>
<p style="font-size: 14px; margin: 5px 0;">
<strong>Status:</strong> ${currentFiles.length} files in directory, ${episodes.length} episodes available.
</p>
`;
// 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 = `
<strong>Episode Information:</strong>
${episodes.length} episodes available for this show.
<br><small>Episode details are displayed in the sidebar.</small>
`;
// 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 = '&times;';
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 = `<strong>Loading:</strong> ${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 = `
<strong>File:</strong> ${fileName}<br>
<strong>Size:</strong> ${(stats.size / (1024 * 1024)).toFixed(2)} MB<br>
<strong>Path:</strong> ${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);
} else {
// For other formats, try to create a video player
videoContainer.innerHTML = `
<video id="preview-video" controls style="width: 100%; max-width: 800px; height: auto; margin: 0 auto; display: block;">
<source src="${filePath}" type="video/mp4">
Your browser does not support the video tag.
</video>
`;
// Hide loading
loadingIndicator.style.display = 'none';
}
} catch (error) {
console.error('Error loading video preview:', error);
fileInfo.innerHTML = `<strong>Error:</strong> Could not load preview for ${filePath}`;
videoContainer.innerHTML = '<p style="color: #dc3545; text-align: center;">Error loading video preview</p>';
loadingIndicator.style.display = 'none';
}
}