Add ffmpeg integration for video duration detection and display in mm:ss format
This commit is contained in:
parent
18ffdccb62
commit
15d0555db0
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