Add ffmpeg integration for video duration detection and display in mm:ss format

This commit is contained in:
Jarian Cottingham 2026-02-18 20:52:55 -06:00
parent d0cc78673a
commit c50bcb2cca
5 changed files with 438 additions and 0 deletions

49
.gitignore vendored Normal file
View File

@ -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

49
package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"fluent-ffmpeg": "^2.1.3"
}
}

215
renderer.js Normal file
View File

@ -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 = '<p>No media files found in this directory.</p>';
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 = `
<div class="file-name" data-file-path="${file.path}">${file.name}</div>
<div class="file-duration">${duration}</div>
<div class="file-quality">${quality}</div>
`;
// 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 = `<p>Error: ${error.message}</p>`;
}
}
// Display search results
function displaySearchResults(results) {
searchResultsEl.innerHTML = '';
if (results.length === 0) {
searchResultsEl.innerHTML = '<p>No shows found.</p>';
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');

120
utils/fileUtils.js Normal file
View File

@ -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>} - 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<string>} - 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<Object>} - 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
};