MovieMapper/utils/fileUtils.js

273 lines
8.3 KiB
JavaScript

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 and folders (non-recursive)
* @param {string} directoryPath - Path to the directory to scan
* @param {Function} progressCallback - Optional callback for progress updates (current, total, fileName)
* @returns {Promise<Array>} - Array of media files and folders
*/
async function scanDirectory(directoryPath, progressCallback = null) {
try {
const files = await fs.promises.readdir(directoryPath);
const items = [];
// First pass: collect folders and media files to process
const folders = [];
const mediaFilesToProcess = [];
for (const file of files) {
// Skip hidden files
if (file.startsWith('.')) continue;
const filePath = path.join(directoryPath, file);
try {
const stat = await fs.promises.stat(filePath);
if (stat.isDirectory()) {
folders.push({
path: filePath,
name: file,
isFolder: true,
duration: '',
quality: '',
fps: ''
});
} else if (isMediaFile(filePath)) {
mediaFilesToProcess.push({ file, filePath, stat });
}
} catch (fileError) {
console.warn(`Skipping file/directory due to permission error: ${filePath}`);
continue;
}
}
// Add folders first (no processing needed)
items.push(...folders);
// Helper to yield to event loop (allows IPC messages to be sent)
const yieldToEventLoop = () => new Promise(resolve => setImmediate(resolve));
// Process media files with progress updates
const totalMedia = mediaFilesToProcess.length;
// Send initial progress
if (progressCallback && totalMedia > 0) {
progressCallback(0, totalMedia, 'Starting...');
await yieldToEventLoop();
}
for (let i = 0; i < mediaFilesToProcess.length; i++) {
const { file, filePath, stat } = mediaFilesToProcess[i];
// Report progress before processing this file
if (progressCallback) {
progressCallback(i, totalMedia, file);
await yieldToEventLoop();
}
let fileData = {
path: filePath,
name: file,
size: stat.size,
modified: stat.mtime,
duration: '00:00',
isProblematic: false,
quality: 'unknown',
isFolder: false
};
try {
const durationResult = await extractFileDuration(filePath);
fileData.duration = durationResult.duration;
fileData.isProblematic = durationResult.isProblematic;
} catch (error) {
console.warn(`Failed to extract duration for ${filePath}:`, error.message);
}
try {
const quality = await extractVideoQuality(filePath);
fileData.quality = quality.quality;
fileData.fps = quality.fps;
} catch (error) {
console.warn(`Failed to extract quality for ${filePath}:`, error.message);
fileData.quality = 'unknown';
fileData.fps = 'unknown';
}
items.push(fileData);
// Report completion of this file
if (progressCallback) {
progressCallback(i + 1, totalMedia, file);
}
}
// Sort: folders first, then files
items.sort((a, b) => {
if (a.isFolder && !b.isFolder) return -1;
if (!a.isFolder && b.isFolder) return 1;
return a.name.localeCompare(b.name);
});
return items;
} 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) {
console.warn(`ffprobe error for ${filePath}:`, err.message);
// Try alternative approach - attempt to get duration from file stats or use fallback
try {
const stats = await fs.promises.stat(filePath);
console.log(`File size for ${filePath}: ${stats.size} bytes`);
// For now, return 00:00 as fallback for problematic files
resolve({ duration: '00:00', isProblematic: true });
} catch (statsError) {
console.warn(`Could not get file stats for ${filePath}:`, statsError.message);
resolve({ duration: '00:00', isProblematic: true });
}
return;
}
const duration = metadata.format.duration;
if (!duration) {
resolve({ duration: '00:00', isProblematic: false });
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({ duration: formattedDuration, isProblematic: false });
});
});
}
/**
* Extract video quality information using ffmpeg
* @param {string} filePath - Path to the media file
* @returns {Promise<Object>} - Quality and frame rate information
*/
async function extractVideoQuality(filePath) {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(filePath, (err, metadata) => {
if (err) {
console.warn(`ffprobe error for quality extraction ${filePath}:`, err.message);
resolve({ quality: 'unknown', fps: 'unknown' });
return;
}
try {
// Find the video stream
const videoStream = metadata.streams.find(stream => stream.codec_type === 'video');
if (!videoStream) {
resolve({ quality: 'unknown', fps: 'unknown' });
return;
}
// Get resolution
const width = videoStream.width || 0;
const height = videoStream.height || 0;
// Determine quality based on height
let quality = 'unknown';
if (height >= 2160) {
quality = '4K';
} else if (height >= 1440) {
quality = '1440p';
} else if (height >= 1080) {
quality = '1080p';
} else if (height >= 720) {
quality = '720p';
} else if (height >= 480) {
quality = '480p';
} else {
quality = 'unknown';
}
// Get frame rate
let fps = 'unknown';
if (videoStream.r_frame_rate) {
// Parse frame rate like "30/1" or "25/1"
const fpsParts = videoStream.r_frame_rate.split('/');
if (fpsParts.length === 2) {
const fpsValue = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
fps = `${fpsValue}fps`;
} else {
const fpsValue = Math.round(parseFloat(videoStream.r_frame_rate));
fps = `${fpsValue}fps`;
}
}
// Return both quality and fps
resolve({ quality: quality, fps: fps });
} catch (error) {
console.warn(`Error extracting quality for ${filePath}:`, error.message);
resolve({ quality: 'unknown', fps: 'unknown' });
}
});
});
}
/**
* 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 and quality using ffmpeg
let duration = '00:00';
let quality = 'unknown';
try {
duration = await extractFileDuration(filePath);
} catch (error) {
console.warn(`Failed to extract duration for ${filePath}:`, error.message);
}
try {
quality = await extractVideoQuality(filePath);
} catch (error) {
console.warn(`Failed to extract quality for ${filePath}:`, error.message);
}
return {
duration: duration,
quality: quality
};
}
module.exports = {
scanDirectory,
extractFileMetadata,
isMediaFile,
extractFileDuration,
extractVideoQuality
};