diff --git a/AGENTS.md b/AGENTS.md index 2d832f4..ae67407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,31 +8,37 @@ MovieMapper is an Electron-based desktop application for organizing and managing ### Build Commands - `npm install` - Install project dependencies - `npm start` - Run the application using Electron -- `electron .` - Alternative way to run the application ### Test Commands -- `npm test` - Currently outputs "Error: no test specified" (default test script) -- `node test-api.js` - Run API connectivity test for TheTVDB -- `node main.js` - Run main process directly for debugging (may require environment setup) +- `node test-api.js` - Test TheTVDB API connectivity +- `node test-command-line.js` - Test command-line argument parsing +- `node test-audit.js` - Run unit tests for audit logging (uses `node:test`) +- `node test-functional.js` - Run functional tests for UI elements +- `node test-core.js` - Verify core functionality implementation +- `node test-implementation.js` - Test implemented features +- `node test-audit-functionality.js` - Test audit file operations ### Syntax Checking - `node --check renderer.js` - Check renderer.js for syntax errors - `node --check main.js` - Check main.js for syntax errors - `node --check utils/fileUtils.js` - Check fileUtils.js for syntax errors +### Running Single Tests +Use `node .js` to run individual test files. Tests use Node.js built-in `node:test` framework. + ### Linting -- No explicit linting tools configured in package.json +- No explicit linting tools configured - Code style follows JavaScript/Node.js conventions -- Use JSDoc comments for documentation ## Architecture Overview ### Main Process (`main.js`) -- Handles all IPC communication with renderer -- File system operations (moving, renaming files) +- Electron main process handling application lifecycle +- IPC handlers for renderer communication (`ipcMain.handle()`) +- File system operations (moving, renaming, scanning) - TheTVDB API integration with token authentication - Audit logging to `.audit` files in directories -- Progress reporting via IPC events +- Progress reporting via `mainWindow.webContents.send()` ### Renderer Process (`renderer.js`) - UI state management and DOM manipulation @@ -50,43 +56,41 @@ MovieMapper is an Electron-based desktop application for organizing and managing ## Code Style Guidelines ### Imports -- Use standard Node.js `require()` syntax for modules +- Use standard Node.js `require()` syntax - Import modules at the top of files -- Group imports by standard library, external modules, then local modules -- Use descriptive names for modules (e.g., `const fs = require('fs')`) +- Group imports: standard library → external modules → local modules +- Use descriptive names: `const fs = require('fs')` ### Formatting -- Use 2-space indentation -- Use single quotes for strings -- Place opening braces on the same line as the statement -- Add spaces around operators and after commas -- No semicolons required (follows JavaScript convention) -- Use consistent spacing around code blocks +- 2-space indentation +- Single quotes for strings +- Opening braces on same line as statement +- Spaces around operators and after commas +- No semicolons required +- Consistent spacing around code blocks ### Naming Conventions -- Use camelCase for variables and functions -- Use PascalCase for constructors -- Use UPPER_CASE for constants -- Use descriptive variable names +- camelCase for variables and functions +- PascalCase for constructors +- UPPER_CASE for constants - Function names should be verbs (e.g., `scanDirectory`, `extractFileMetadata`) -- File names should be lowercase with hyphens (e.g., `file-utils.js`) +- File names lowercase with hyphens (e.g., `file-utils.js`) ### Types -- This is a JavaScript project without TypeScript -- Use JSDoc comments to document function parameters and return values -- Use descriptive parameter names +- JavaScript project without TypeScript +- Use JSDoc comments for function parameters and return values - Include type information in comments for complex objects ### Error Handling - Use try/catch blocks for async operations - Handle file system errors gracefully -- Log warnings for problematic files but don't crash the application +- Log warnings for problematic files without crashing - Return consistent error structures: `{ success: true/false, error?: string, ... }` - Use specific error messages with context information - Implement proper error propagation throughout the application ### IPC Communication Patterns -- Main process handlers use `ipcMain.handle()` for async operations +- Main process uses `ipcMain.handle()` for async operations - Renderer invokes with `ipcRenderer.invoke()` returning promises - Progress updates use `mainWindow.webContents.send()` for push events - Renderer listens with `ipcRenderer.on()` for progress events @@ -108,13 +112,13 @@ MovieMapper is an Electron-based desktop application for organizing and managing - Implement authentication token caching for performance ### Code Patterns -- Use async/await for handling asynchronous operations +- Use async/await for asynchronous operations - Handle permission errors gracefully when scanning directories - Provide user feedback through console warnings for problematic files - Use descriptive variable names that reflect their purpose - Implement comprehensive logging for debugging -- Use consistent IPC patterns for communication between main and renderer processes -- Use `setImmediate` yields when sending IPC messages in loops to allow event loop processing +- Use consistent IPC patterns for main/renderer communication +- Use `setImmediate` yields when sending IPC messages in loops ### Debugging - Built-in debugging utilities for problematic files @@ -125,17 +129,17 @@ MovieMapper is an Electron-based desktop application for organizing and managing - Audit logs written to `.audit` files in each directory ### Environment Configuration -- API keys stored in `.env` file -- Environment variables should be validated on application startup -- Provide clear error messages when environment variables are missing +- API keys stored in `.env` file (root level only) +- Environment variables validated on application startup +- Clear error messages when environment variables missing - Use `dotenv` package for loading environment variables ## Key Features Implementation ### File Tagging System -- Files can be tagged as "extra" or "commentary" -- Tags stored as data attributes on DOM elements: `data-tagged-extra`, `data-tagged-commentary` -- Visual feedback with color changes and glow effects when tagged +- Files tagged as "extra" or "commentary" +- Tags stored as data attributes: `data-tagged-extra`, `data-tagged-commentary` +- Visual feedback with color changes and glow effects - Play button enables only when file is tagged - Individual play button moves single file - Floating action button (FAB) moves all tagged files at once @@ -159,58 +163,6 @@ MovieMapper is an Electron-based desktop application for organizing and managing - Audit log entry written for each move operation - Files removed from UI after successful move -## Recent Changes (Session 2026-02-22) - -### Bug Fixes -1. **Fixed duplicate `moveTaggedFile` function** - Two definitions were overwriting each other -2. **Fixed missing click handler for FAB** - Added `moveAllTaggedFiles()` on floating circle click -3. **Fixed "extra" vs "extras" folder naming** - Now correctly uses "extras" for Jellyfin -4. **Fixed progressBar reference error** - Removed reference after switching to spinner -5. **Removed non-existent test-api-btn reference** - Was causing errors on load - -### New Features -1. **Folder navigation** - Click folders to navigate into them -2. **Non-recursive scanning** - Only shows current directory contents -3. **Progress spinner** - Shows file being processed with count -4. **Batch file moving** - FAB moves all tagged files at once -5. **Visual feedback** - FAB turns yellow while processing - -### UI Improvements -1. **Dark theme** - Modern dark color scheme (#1a1a2e, #16213e, #0f3460) -2. **Clean layout** - Search/shows on left sidebar, files on right -3. **Accent color** - Pink/red accent (#e94560) -4. **Better typography** - System font stack -5. **Smooth animations** - Hover effects and transitions - -## Jellyfin Compatible Folder Names - -### Extras Folders (place inside movie/show folder) -- `behind the scenes` - Behind-the-scenes content -- `deleted scenes` - Deleted scenes -- `interviews` - Cast/crew interviews -- `scenes` - Individual scenes -- `samples` - Sample clips -- `shorts` - Short films -- `featurettes` - Featurettes -- `clips` - Clips -- `other` - Generic catch-all for unknown extras -- `extras` - Generic catch-all for unknown extras (CURRENTLY IMPLEMENTED) -- `trailers` - Trailers and previews -- `theme-music` - Theme music audio files -- `backdrops` - Backdrop videos - -### Special Single-File Names (in same folder as media) -- `trailer` - Single trailer file -- `sample` - Single sample file -- `theme` - Theme song audio file - -### File Suffix Options (append to filename) -- `-trailer`, `.trailer`, `_trailer`, ` trailer` -- `-sample`, `.sample`, `_sample`, ` sample` -- `-scene`, `-clip`, `-interview` -- `-behindthescenes`, `-deleted`, `-deletedscene` -- `-featurette`, `-short`, `-other`, `-extra` - ## Known Issues & Future Work ### Known Issues @@ -228,6 +180,21 @@ MovieMapper is an Electron-based desktop application for organizing and managing 7. Add confirmation dialog before moving files ## Execution Constraints -- All commands that might block indefinitely must be run with a timeout -- Example: `timeout 30 npm start` to run the application with 30-second timeout -- Always run syntax checks after modifying JS files: `node --check ` \ No newline at end of file +- All commands that might block indefinitely must run with timeout +- Example: `timeout 30 npm start` to run with 30-second timeout +- Always run syntax checks after modifying JS files: `node --check ` + +## Jellyfin Compatible Folder Names + +### Extras Folders (place inside movie/show folder) +- `behind the scenes`, `deleted scenes`, `interviews`, `scenes`, `samples`, `shorts`, `featurettes`, `clips`, `other`, `extras`, `trailers`, `theme-music`, `backdrops` + +### Special Single-File Names (in same folder as media) +- `trailer`, `sample`, `theme` + +### File Suffix Options (append to filename) +- `-trailer`, `.trailer`, `_trailer`, ` trailer` +- `-sample`, `.sample`, `_sample`, ` sample` +- `-scene`, `-clip`, `-interview` +- `-behindthescenes`, `-deleted`, `-deletedscene` +- `-featurette`, `-short`, `-other`, `-extra` \ No newline at end of file diff --git a/index.html b/index.html index c5e2586..cc20764 100644 --- a/index.html +++ b/index.html @@ -280,6 +280,13 @@ cursor: pointer; } + .episode-number-container { + display: flex; + align-items: center; + gap: 4px; + margin-right: 12px; + } + .episode-number { min-width: 40px; height: 28px; @@ -291,7 +298,36 @@ font-weight: 600; font-size: 12px; border-radius: 4px; - margin-right: 12px; + flex: 1; + } + + .episode-arrow { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background-color: #0f3460; + color: #eee; + font-size: 14px; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; + flex-shrink: 0; + } + + .episode-arrow:hover { + background-color: #1a1a2e; + } + + .episode-arrow:active { + background-color: #16213e; + } + + .episode-arrow:disabled { + opacity: 0.3; + cursor: not-allowed; } .drag-handle { @@ -487,7 +523,10 @@ - +
+ + +
diff --git a/main.js b/main.js index 6c359c7..7d48470 100644 --- a/main.js +++ b/main.js @@ -50,7 +50,7 @@ function createWindow() { } // Open the DevTools. - // mainWindow.webContents.openDevTools(); + mainWindow.webContents.openDevTools(); } // IPC handler for selecting directory @@ -309,36 +309,68 @@ ipcMain.handle('begin-mapping', async (event, { directory, files, tvdbId }) => { const oldPath = file.filePath; const ext = path.extname(oldPath); - // Format episode number with leading zero - const epNum = String(file.episodeNumber).padStart(2, '0'); const seasonNum = String(seasonNumber).padStart(2, '0'); - // Build new filename: "ShowName S01E01 - 1080p.ext" - let newFileName = `${showName} S${seasonNum}E${epNum}`; - if (file.quality && file.quality !== 'N/A' && file.quality !== '-') { - newFileName += ` - ${file.quality}`; - } - newFileName += ext; + // Handle episode ranges (e.g., "1-3" for multiple episodes) + const episodeStart = file.episodeStart || file.episodeNumber || 1; + const episodeEnd = file.episodeEnd !== undefined ? file.episodeEnd : episodeStart; - const newPath = path.join(path.dirname(oldPath), newFileName); - - // Skip if file already has the correct name - if (oldPath === newPath) { - writeLog(`[BEGIN-MAPPING] File already mapped: ${path.basename(oldPath)}`); + // If range is valid (start < end), create multi-episode files + if (episodeEnd > episodeStart) { + // Multi-episode range (e.g., "ShowName S01E01-E03 - 1080p.ext") + const startEpNum = String(episodeStart).padStart(2, '0'); + const endEpNum = String(episodeEnd).padStart(2, '0'); + + let newFileName = `${showName} S${seasonNum}E${startEpNum}-${endEpNum}`; + if (file.quality && file.quality !== 'N/A' && file.quality !== '-') { + newFileName += ` - ${file.quality}`; + } + newFileName += ext; + + const newPath = path.join(path.dirname(oldPath), newFileName); + + writeLog(`[BEGIN-MAPPING] Processing range: ${episodeStart}-${episodeEnd}, new filename: ${newFileName}`); + + // Check if target already exists + if (fs.existsSync(newPath)) { + writeLog(`[BEGIN-MAPPING] Target exists, skipping: ${newFileName}`); + errorCount++; + continue; + } + + fs.renameSync(oldPath, newPath); + writeLog(`[BEGIN-MAPPING] Renamed range: ${path.basename(oldPath)} -> ${newFileName}`); + successCount++; + } else { + // Single episode (original logic) + const epNum = String(episodeStart).padStart(2, '0'); + + let newFileName = `${showName} S${seasonNum}E${epNum}`; + if (file.quality && file.quality !== 'N/A' && file.quality !== '-') { + newFileName += ` - ${file.quality}`; + } + newFileName += ext; + + const newPath = path.join(path.dirname(oldPath), newFileName); + + // Skip if file already has the correct name + if (oldPath === newPath) { + writeLog(`[BEGIN-MAPPING] File already mapped: ${path.basename(oldPath)}`); + successCount++; + continue; + } + + // Check if target already exists + if (fs.existsSync(newPath)) { + writeLog(`[BEGIN-MAPPING] Target exists, skipping: ${newFileName}`); + errorCount++; + continue; + } + + fs.renameSync(oldPath, newPath); + writeLog(`[BEGIN-MAPPING] Renamed: ${path.basename(oldPath)} -> ${newFileName}`); successCount++; - continue; } - - // Check if target already exists - if (fs.existsSync(newPath)) { - writeLog(`[BEGIN-MAPPING] Target exists, skipping: ${newFileName}`); - errorCount++; - continue; - } - - fs.renameSync(oldPath, newPath); - writeLog(`[BEGIN-MAPPING] Renamed: ${path.basename(oldPath)} -> ${newFileName}`); - successCount++; } catch (fileErr) { writeLog(`[BEGIN-MAPPING] Error renaming file: ${fileErr.message}`); errorCount++; diff --git a/renderer.js b/renderer.js index b47daf4..7576c5c 100644 --- a/renderer.js +++ b/renderer.js @@ -11,6 +11,7 @@ const showDetailsEl = document.getElementById('show-details'); const progressContainer = document.getElementById('progress-container'); const progressText = document.getElementById('progress-text'); const progressCount = document.getElementById('progress-count'); +const updateRangesBtn = document.getElementById('update-ranges-btn'); // Current state let currentDirectory = null; @@ -24,12 +25,28 @@ let selectedSeasonEpisodeCount = 0; selectDirBtn.addEventListener('click', selectDirectory); searchInput.addEventListener('input', debounce(searchShows, 300)); -// Add click handler for the floating circle (play button) to move all tagged files -document.getElementById('tagged-circle').addEventListener('click', moveAllTaggedFiles); +// 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 Update Ranges button +if (updateRangesBtn) { + updateRangesBtn.addEventListener('click', function() { + updateEpisodeNumbers(); + checkEpisodeCountMatch(); + }); +} // 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); @@ -227,7 +244,11 @@ function displayFiles(files) { fileItem.innerHTML = `
⋮⋮
-
${episodeNumber}
+
+ +
${episodeNumber}
+ +
${file.name}
${duration}
${quality}
@@ -255,6 +276,24 @@ function displayFiles(files) { 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 => { @@ -413,16 +452,168 @@ function updateEpisodeNumbers() { const fileItems = fileListEl.querySelectorAll('.file-item:not(.folder-item)'); let episodeNum = 1; - fileItems.forEach(item => { + 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) { - episodeEl.textContent = episodeNum; + 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; - episodeNum++; + console.log(`[updateEpisodeNumbers] Item ${index}: calculated start=${start}, end=${end}, display=${rangeText}, next episodeNum=${end + 1}`); + episodeNum = end + 1; } }); } +// 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 @@ -655,7 +846,7 @@ async function beginMapping() { btn.disabled = true; btn.textContent = 'Mapping...'; - // Collect file data with episode numbers from UI order + // Collect file data with episode ranges from UI order const filesToMap = []; fileItems.forEach((item, index) => { const fileNameEl = item.querySelector('.file-name'); @@ -663,10 +854,14 @@ async function beginMapping() { 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 : '', - episodeNumber: episodeNumEl ? parseInt(episodeNumEl.textContent) : (index + 1) + episodeStart: episodeStart, + episodeEnd: episodeEnd }); } }); @@ -847,9 +1042,31 @@ function checkEpisodeCountMatch() { const fileList = document.getElementById('file-list'); // Count only actual media files (not folders) const mediaFiles = document.querySelectorAll('.file-item:not(.folder-item)'); - const mediaCount = mediaFiles.length; - if (selectedSeasonEpisodeCount > 0 && mediaCount === selectedSeasonEpisodeCount) { + // 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)';