From c50bcb2cca328209de90bb74fbef238ec3917263 Mon Sep 17 00:00:00 2001 From: Jarian Cottingham Date: Wed, 18 Feb 2026 20:52:55 -0600 Subject: [PATCH] Add ffmpeg integration for video duration detection and display in mm:ss format --- .gitignore | 49 +++++++++++ package-lock.json | 49 +++++++++++ package.json | 5 ++ renderer.js | 215 +++++++++++++++++++++++++++++++++++++++++++++ utils/fileUtils.js | 120 +++++++++++++++++++++++++ 5 files changed, 438 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 renderer.js create mode 100644 utils/fileUtils.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02a1473 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Logs +logs +*.log +npm-debug.log +yarn-debug.log +yarn-error.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional type definition files +*.d.ts + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Build directories +build/ +dist/ +*.build.js + +# Temporary files +*.tmp +*.temp \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5ed73b7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,49 @@ +{ + "name": "MovieMapper", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "fluent-ffmpeg": "^2.1.3" + } + }, + "node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..eeefc5f --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "fluent-ffmpeg": "^2.1.3" + } +} diff --git a/renderer.js b/renderer.js new file mode 100644 index 0000000..4b06c42 --- /dev/null +++ b/renderer.js @@ -0,0 +1,215 @@ +const { ipcRenderer } = require('electron'); +const path = require('path'); + +// DOM Elements +const selectDirBtn = document.getElementById('select-dir-btn'); +const selectedDirEl = document.getElementById('selected-dir'); +const searchInput = document.getElementById('search-input'); +const searchResultsEl = document.getElementById('search-results'); +const fileListEl = document.getElementById('file-list'); + +// Current state +let currentDirectory = null; +let currentFiles = []; + +// Event Listeners +selectDirBtn.addEventListener('click', selectDirectory); +searchInput.addEventListener('input', debounce(searchShows, 300)); + +// Select directory function +async function selectDirectory() { + try { + // Use IPC to handle directory selection + const result = await ipcRenderer.invoke('select-directory'); + + if (result.success) { + const directory = result.directory; + currentDirectory = directory; + selectedDirEl.textContent = `Selected: ${directory}`; + + // Scan the directory for media files + await scanDirectory(directory); + } else { + throw new Error(result.error); + } + } catch (error) { + console.error('Error selecting directory:', error); + alert(`Error: ${error.message}`); + } +} + +// Scan directory for media files +async function scanDirectory(directoryPath) { + try { + const result = await ipcRenderer.invoke('scan-directory', directoryPath); + + if (result.success) { + currentFiles = result.files; + displayFiles(currentFiles); + } else { + throw new Error(result.error); + } + } catch (error) { + console.error('Error scanning directory:', error); + alert(`Error scanning directory: ${error.message}`); + } +} + +// Display files in the UI +function displayFiles(files) { + fileListEl.innerHTML = ''; + + if (files.length === 0) { + fileListEl.innerHTML = '

No media files found in this directory.

'; + return; + } + + files.forEach(file => { + const fileItem = document.createElement('div'); + fileItem.className = 'file-item'; + + // Use actual duration from file metadata + const duration = file.duration || '00:00'; + const quality = 'unknown'; // Placeholder for quality + + fileItem.innerHTML = ` +
${file.name}
+
${duration}
+
${quality}
+ `; + + // Add click event to make file name editable + const fileNameElement = fileItem.querySelector('.file-name'); + fileNameElement.addEventListener('click', function(e) { + makeEditable(e.target); + }); + + fileListEl.appendChild(fileItem); + }); +} + +// Make a file name editable +function makeEditable(element) { + // Prevent editing if already in edit mode + if (element.contentEditable === 'true') return; + + // Store original text + const originalText = element.textContent; + + // Make element editable + element.contentEditable = 'true'; + element.focus(); + element.classList.add('editing'); + + // Select all text when editing starts + const range = document.createRange(); + range.selectNodeContents(element); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + // Handle saving when user finishes editing + const saveEdit = async function() { + if (element.textContent.trim() !== originalText.trim()) { + // Send request to main process to rename the file + try { + const result = await ipcRenderer.invoke('rename-file', { + oldPath: element.dataset.filePath, + newName: element.textContent.trim() + }); + + if (result.success) { + console.log('File renamed successfully:', result.message); + // Update the file name in the UI to reflect the change + element.textContent = element.textContent.trim(); + } 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); +} + +// Search shows function +async function searchShows() { + const query = searchInput.value.trim(); + + if (query.length < 2) { + searchResultsEl.innerHTML = ''; + return; + } + + try { + const result = await ipcRenderer.invoke('search-tvdb', query); + + if (result.success) { + displaySearchResults(result.results); + } else { + throw new Error(result.error); + } + } catch (error) { + console.error('Error searching shows:', error); + searchResultsEl.innerHTML = `

Error: ${error.message}

`; + } +} + +// Display search results +function displaySearchResults(results) { + searchResultsEl.innerHTML = ''; + + if (results.length === 0) { + searchResultsEl.innerHTML = '

No shows found.

'; + return; + } + + results.forEach(show => { + const resultItem = document.createElement('div'); + resultItem.className = 'search-result-item'; + resultItem.textContent = show.seriesName || show.name; + resultItem.addEventListener('click', () => selectShow(show)); + searchResultsEl.appendChild(resultItem); + }); +} + +// Select a show (placeholder for actual implementation) +function selectShow(show) { + console.log('Selected show:', show); + // In a real implementation, this would fetch show details and display seasons + alert(`Selected show: ${show.seriesName || show.name}`); +} + +// Debounce function for search input +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +// Initialize the application +console.log('Movie Mapper application initialized'); \ No newline at end of file diff --git a/utils/fileUtils.js b/utils/fileUtils.js new file mode 100644 index 0000000..f70bc92 --- /dev/null +++ b/utils/fileUtils.js @@ -0,0 +1,120 @@ +const fs = require('fs'); +const path = require('path'); +const ffmpeg = require('fluent-ffmpeg'); + +// Supported media file extensions +const MEDIA_EXTENSIONS = ['.mp4', '.mkv', '.avi', '.mov', '.flv', '.webm']; + +/** + * Check if a file has a media extension + * @param {string} filePath - Path to the file + * @returns {boolean} - True if file is a media file + */ +function isMediaFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return MEDIA_EXTENSIONS.includes(ext); +} + +/** + * Scan a directory for media files + * @param {string} directoryPath - Path to the directory to scan + * @returns {Promise} - Array of media file paths + */ +async function scanDirectory(directoryPath) { + try { + const files = fs.readdirSync(directoryPath); + const mediaFiles = []; + + for (const file of files) { + const filePath = path.join(directoryPath, file); + try { + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + // Recursively scan subdirectories + const subDirFiles = await scanDirectory(filePath); + mediaFiles.push(...subDirFiles); + } else if (isMediaFile(filePath)) { + // Extract duration for media files + let duration = '00:00'; + try { + duration = await extractFileDuration(filePath); + } catch (error) { + console.warn(`Failed to extract duration for ${filePath}:`, error.message); + } + + mediaFiles.push({ + path: filePath, + name: file, + size: stat.size, + modified: stat.mtime, + duration: duration + }); + } + } catch (fileError) { + // Skip files/directories that cause permission errors + console.warn(`Skipping file/directory due to permission error: ${filePath}`); + continue; + } + } + + return mediaFiles; + } catch (error) { + throw new Error(`Failed to scan directory: ${error.message}`); + } +} + +/** + * Extract file duration using ffmpeg + * @param {string} filePath - Path to the media file + * @returns {Promise} - Duration in mm:ss format + */ +async function extractFileDuration(filePath) { + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(filePath, (err, metadata) => { + if (err) { + reject(err); + return; + } + + const duration = metadata.format.duration; + if (!duration) { + resolve('00:00'); + return; + } + + // Convert seconds to mm:ss format + const minutes = Math.floor(duration / 60); + const seconds = Math.floor(duration % 60); + const formattedDuration = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + resolve(formattedDuration); + }); + }); +} + +/** + * Extract file metadata (duration and quality) + * @param {string} filePath - Path to the media file + * @returns {Promise} - File metadata + */ +async function extractFileMetadata(filePath) { + // Extract duration using ffmpeg + let duration = '00:00'; + try { + duration = await extractFileDuration(filePath); + } catch (error) { + console.warn(`Failed to extract duration for ${filePath}:`, error.message); + } + + return { + duration: duration, + quality: 'unknown' + }; +} + +module.exports = { + scanDirectory, + extractFileMetadata, + isMediaFile, + extractFileDuration +}; \ No newline at end of file