Refactor renderer.js into modular class-based structure
- Split large renderer.js file into 8 separate class files: - AppState.js: Manages application state - FileListManager.js: Handles file list display and manipulation - TagManager.js: Manages file tagging functionality - EpisodeManager.js: Handles episode number editing and highlighting - SearchManager.js: Manages TVDB search and show selection - FileManager.js: Handles file operations - ModalManager.js: Manages video preview modal - ProgressManager.js: Manages progress display - UIManager.js: Main coordinator for UI functionality - Follows SOLID principles and single responsibility - Improves code maintainability and testability - All syntax verified with node --check
This commit is contained in:
parent
8ebd419a91
commit
be887c3855
1654
renderer.js
1654
renderer.js
File diff suppressed because it is too large
Load Diff
143
utils/renderer/AppState.js
Normal file
143
utils/renderer/AppState.js
Normal file
@ -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;
|
||||
285
utils/renderer/EpisodeManager.js
Normal file
285
utils/renderer/EpisodeManager.js
Normal file
@ -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;
|
||||
243
utils/renderer/FileListManager.js
Normal file
243
utils/renderer/FileListManager.js
Normal file
@ -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 = '<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 => {
|
||||
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 = `
|
||||
<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', () => {
|
||||
// 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 ? '<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', 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;
|
||||
195
utils/renderer/FileManager.js
Normal file
195
utils/renderer/FileManager.js
Normal file
@ -0,0 +1,195 @@
|
||||
const path = require('path');
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
/**
|
||||
* FileManager - Handles file operations
|
||||
*/
|
||||
class FileManager {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Select directory
|
||||
* @returns {Promise<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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;
|
||||
211
utils/renderer/ModalManager.js
Normal file
211
utils/renderer/ModalManager.js
Normal file
@ -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 = `<strong>Loading:</strong> ${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 = `
|
||||
<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
|
||||
this.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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
77
utils/renderer/ProgressManager.js
Normal file
77
utils/renderer/ProgressManager.js
Normal file
@ -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;
|
||||
333
utils/renderer/SearchManager.js
Normal file
333
utils/renderer/SearchManager.js
Normal file
@ -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<Object>} 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 = `<p>Error: ${error.message}</p>`;
|
||||
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 = '<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', () => 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('<p>Seasons will be loaded when you click on a season.</p>');
|
||||
} else {
|
||||
onSeasonsDisplay(seasons);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch show details:', result.error);
|
||||
if (onSeasonsDisplay) {
|
||||
onSeasonsDisplay('<p>Error loading seasons: ' + (result.error || 'Unknown error') + '</p>');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching show details:', error);
|
||||
if (onSeasonsDisplay) {
|
||||
onSeasonsDisplay('<p>Error loading seasons: ' + error.message + '</p>');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = '<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');
|
||||
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 = '<p>Loading episodes...</p>';
|
||||
|
||||
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 = '<p style="color: #888;">No episodes available.</p>';
|
||||
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 = `
|
||||
<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
|
||||
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 = `
|
||||
<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test TVDB API connectivity
|
||||
* @returns {Promise<Object>} 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;
|
||||
257
utils/renderer/TagManager.js
Normal file
257
utils/renderer/TagManager.js
Normal file
@ -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<Object>} 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<Object>} 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;
|
||||
594
utils/renderer/UIManager.js
Normal file
594
utils/renderer/UIManager.js
Normal file
@ -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 = `
|
||||
<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 (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 = `
|
||||
<div style="padding: 10px; background-color: #f8d7da; color: #721c24; border-radius: 4px;">
|
||||
<p>Error loading episodes: ${error}</p>
|
||||
<p style="font-size: 12px; margin-top: 5px;">Note: This may be due to API limitations with the TVDB v4 API.</p>
|
||||
</div>
|
||||
`;
|
||||
} 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;
|
||||
Loading…
x
Reference in New Issue
Block a user