Add ffmpeg integration for video duration detection and display in mm:ss format
This commit is contained in:
parent
d0cc78673a
commit
c50bcb2cca
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal 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
49
package-lock.json
generated
Normal 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
5
package.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"fluent-ffmpeg": "^2.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
215
renderer.js
Normal file
215
renderer.js
Normal 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
120
utils/fileUtils.js
Normal 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
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user