Files
video-downloader/main.js

412 lines
16 KiB
JavaScript

const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const YTDlpWrap = require('yt-dlp-wrap').default;
const fs = require('fs');
const os = require('os');
const { promisify } = require('util');
const rename = promisify(fs.rename);
const copyFile = promisify(fs.copyFile);
const unlink = promisify(fs.unlink);
const rm = promisify(fs.rm);
let mainWindow;
let currentDownload = null;
// Resolve binary paths
const isDev = !app.isPackaged;
const binDir = isDev
? path.join(__dirname, 'resources', 'bin')
: path.join(process.resourcesPath, 'bin');
const ytDlpPath = path.join(binDir, 'yt-dlp');
const ffmpegPath = path.join(binDir, 'ffmpeg');
// Initialize yt-dlp with local binary
const ytDlpWrap = new YTDlpWrap(ytDlpPath);
// Set ffmpeg path for yt-dlp-wrap and child processes
process.env.PATH = binDir + path.delimiter + process.env.PATH;
async function createWindow() {
mainWindow = new BrowserWindow({
width: 1000,
height: 750,
minWidth: 800,
minHeight: 600,
resizable: true,
title: 'madapes Media Downloader',
icon: path.join(binDir, '..', 'icon.png'), // Point to icon.png in resources
titleBarStyle: 'hidden',
trafficLightPosition: { x: 15, y: 15 },
backgroundColor: '#0f0f0f',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
mainWindow.loadFile('index.html');
mainWindow.on('closed', () => {
if (currentDownload) {
try {
currentDownload.kill();
} catch (e) {
console.error('Failed to kill download on window close:', e);
}
currentDownload = null;
}
mainWindow = null;
});
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
// Helper to safely send messages to renderer
function sendToRenderer(channel, data) {
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents) {
mainWindow.webContents.send(channel, data);
}
}
// Detect videos and formats
ipcMain.handle('detect-videos', async (event, url) => {
console.log(`[Main] Starting video detection for URL: ${url}`);
try {
// Get video info in JSON format. Using --no-playlist for MUCH faster initial detection.
console.log('[Main] Calling ytDlpWrap.getVideoInfo with --no-playlist...');
const info = await ytDlpWrap.getVideoInfo([
url,
'--no-playlist',
'--extractor-args', 'generic:impersonate'
]);
console.log('[Main] ytDlpWrap.getVideoInfo success');
console.log(`[Main] Info keys: ${Object.keys(info).join(', ')}`);
// Handle both single videos and playlists/multi-parts
const entries = Array.isArray(info.entries) ? info.entries.filter(Boolean) : [info];
console.log(`[Main] Found ${entries.length} entries`);
// Detect if it's a playlist or part of one
const isPlaylist = !!info.playlist_count || !!info.entries || url.includes('list=') || !!info.playlist_id || !!info._type === 'playlist';
const playlistCount = info.playlist_count || (Array.isArray(info.entries) ? info.entries.length : (url.includes('list=') ? '?' : 1));
console.log(`[Main] Detection Details - isPlaylist: ${isPlaylist}, playlistCount: ${playlistCount}, info._type: ${info._type}, has_entries: ${!!info.entries}`);
const detected = entries.map((entry) => {
const formats = (entry.formats || [])
.filter((f) => f.url || f.format_id) // Some manifests might not have direct URLs yet
.map((f) => ({
format_id: f.format_id,
ext: f.ext || 'unknown',
resolution: f.resolution || (f.width && f.height ? `${f.width}x${f.height}` : 'audio only'),
width: f.width,
height: f.height,
vcodec: f.vcodec,
acodec: f.acodec,
fps: f.fps,
tbr: f.tbr, // Total bitrate
vbr: f.vbr, // Video bitrate
abr: f.abr, // Audio bitrate
filesize: f.filesize || f.filesize_approx || null,
protocol: f.protocol,
format_note: f.format_note,
quality: f.quality,
has_video: f.vcodec && f.vcodec !== 'none',
has_audio: f.acodec && f.acodec !== 'none'
}))
// Sort: combined (video+audio) first, then by quality
.sort((a, b) => {
const typeScore = (x) => {
if (x.has_video && x.has_audio) return 3;
if (x.has_video) return 2;
if (x.has_audio) return 1;
return 0;
};
const aScore = typeScore(a) * 1e9 + (a.height || 0) * 1e6 + (a.tbr || 0);
const bScore = typeScore(b) * 1e9 + (b.height || 0) * 1e6 + (b.tbr || 0);
return bScore - aScore;
});
return {
id: entry.id,
title: entry.title || 'Unknown Title',
webpage_url: entry.webpage_url || url,
uploader: entry.uploader || entry.channel || 'Generic Site',
duration: entry.duration || 0,
thumbnail: entry.thumbnail || entry.thumbnails?.[0]?.url || '',
description: entry.description || '',
formats: formats
};
});
console.log(`[Main] Detection complete. Returning ${detected.length} items. Playlist: ${isPlaylist} (${playlistCount})`);
return {
success: true,
items: detected,
isPlaylist: isPlaylist,
playlistCount: playlistCount
};
} catch (error) {
console.error('Detection error:', error);
return {
success: false,
message: error.message || 'Failed to detect videos'
};
}
});
// Sanitize filename for safe saving
function sanitizeFilename(name) {
return name
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, ' ')
.trim();
}
// Handle download request
ipcMain.handle('start-download', async (event, payload) => {
const { url, formatId, extractAudio, audioFormat, title, downloadPlaylist } = payload;
console.log(`[Main] Starting download for URL: ${url}, Format: ${formatId}, AudioOnly: ${extractAudio}, Title: ${title}, Playlist: ${downloadPlaylist}`);
let tempDir = null;
try {
// Show save dialog
const defaultExt = extractAudio ? (audioFormat || 'mp3') : 'mp4';
const sanitizedTitle = title ? sanitizeFilename(title).substring(0, 50) : 'download';
if (!mainWindow || mainWindow.isDestroyed()) {
return { success: false, message: 'Window closed' };
}
const result = await dialog.showSaveDialog(mainWindow, {
title: extractAudio ? 'Save audio as' : 'Save video as',
defaultPath: `${sanitizedTitle}.${defaultExt}`,
filters: extractAudio ? [
{ name: 'Audio Files', extensions: ['mp3', 'm4a', 'opus', 'wav'] },
{ name: 'All Files', extensions: ['*'] }
] : [
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'webm', 'mov', 'avi', 'flv'] },
{ name: 'Audio Files', extensions: ['mp3', 'm4a', 'opus', 'wav'] },
{ name: 'All Files', extensions: ['*'] }
]
});
if (result.canceled) {
return { success: false, message: 'Cancelled' };
}
const savePath = result.filePath;
// Create a unique temp directory
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'madapes-downloader-'));
const tempOutputFile = path.join(tempDir, `processed.${defaultExt}`);
// Build download arguments
const args = [
url,
'--newline',
'--extractor-args', 'generic:impersonate'
];
if (!downloadPlaylist) {
args.push('--no-playlist');
}
// Use a simple template for output to be robust across environments
const outputTemplate = path.join(tempDir, 'media-%(autonumber)03d.%(ext)s');
args.push('-o', outputTemplate);
console.log(`[Main] Executing yt-dlp with command: ${ytDlpPath} ${args.join(' ')}`);
// Add format selection
if (extractAudio) {
args.push('--extract-audio');
args.push('--audio-format', audioFormat || 'mp3');
args.push('--postprocessor-args', `ffmpeg:-y`);
args.push('-o', tempOutputFile);
if (formatId && formatId !== 'best') {
args.push('-f', formatId);
} else {
args.push('-f', 'bestaudio/best');
}
} else if (formatId && formatId !== 'best') {
args.push('-f', formatId);
} else {
// Default to best quality with video+audio
args.push('-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best');
}
let lastProgress = 0;
let isConverting = false;
let totalItems = 1;
let currentItem = 1;
// Wrap download in a Promise to correctly await process completion
const downloadPromise = new Promise((resolve, reject) => {
currentDownload = ytDlpWrap.exec(args);
currentDownload.on('progress', (progress) => {
try {
let percent = 0;
if (progress.percent) {
const percentStr = String(progress.percent).replace('%', '');
percent = parseFloat(percentStr);
}
// If progress reaches 100 but the process is still running, it's likely converting
if (percent >= 100 && extractAudio && !isConverting) {
isConverting = true;
}
// Aggregate progress for multi-item downloads
const aggregatePercent = ((currentItem - 1) * 100 + percent) / totalItems;
if (Math.abs(aggregatePercent - lastProgress) > 0.1 || percent === 100) {
lastProgress = aggregatePercent;
let statusMsg = isConverting ? 'Converting to MP3...' : `Downloading... ${percent.toFixed(1)}%`;
if (totalItems > 1) {
statusMsg = `Downloading item ${currentItem} of ${totalItems}... (${percent.toFixed(1)}%)`;
}
sendToRenderer('download-progress', {
percent: aggregatePercent,
itemPercent: percent,
currentItem: currentItem,
totalItems: totalItems,
speed: progress.currentSpeed || '',
eta: progress.eta || '',
totalSize: progress.totalSize || '',
status: statusMsg
});
}
} catch (err) {
console.error('Progress parsing error:', err);
}
});
currentDownload.on('ytDlpEvent', (eventType, eventData) => {
console.log(`[Main] ytDlpEvent: ${eventType} | Data: ${eventData}`);
// Check for multi-item info in logs
if (eventType === 'download') {
const itemMatch = eventData.match(/Downloading item (\d+) of (\d+)/);
if (itemMatch) {
currentItem = parseInt(itemMatch[1]);
totalItems = parseInt(itemMatch[2]);
}
sendToRenderer('download-status', eventData);
}
});
currentDownload.on('close', (code) => {
console.log(`[Main] yt-dlp process closed with code: ${code}`);
if (code === 0) {
resolve();
} else {
reject(new Error(`yt-dlp exited with code ${code}`));
}
});
currentDownload.on('error', (error) => {
console.error('[Main] yt-dlp process error:', error);
sendToRenderer('download-error', error.message);
reject(error);
});
});
await downloadPromise;
console.log('[Main] Download promise resolved.');
// Verify and move files from temp to final destination
const files = fs.readdirSync(tempDir).sort();
console.log(`[Main] Files in tempDir: ${JSON.stringify(files)}`);
if (files.length === 0) {
console.error(`[Main] Error: No files found in ${tempDir}`);
throw new Error('No files were downloaded');
}
sendToRenderer('download-progress', {
percent: 100,
status: 'Finishing up...'
});
if (files.length === 1) {
// Single file: move to the exact savePath the user chose
const actualFile = path.join(tempDir, files[0]);
try {
await rename(actualFile, savePath);
} catch (moveErr) {
await copyFile(actualFile, savePath);
await unlink(actualFile);
}
} else {
// Multiple files: creates a folder if savePath was a file, or puts them in the Dir
// Actually, dialog.showSaveDialog returns a FILE path.
// We'll create a folder at savePath (minus extension) and put all files there.
const targetFolder = savePath.replace(/\.[^/.]+$/, "");
if (!fs.existsSync(targetFolder)) {
fs.mkdirSync(targetFolder, { recursive: true });
}
for (const file of files) {
const src = path.join(tempDir, file);
const dest = path.join(targetFolder, file);
try {
await rename(src, dest);
} catch (moveErr) {
await copyFile(src, dest);
await unlink(src);
}
}
}
sendToRenderer('download-complete', {
success: true,
path: files.length === 1 ? savePath : savePath.replace(/\.[^/.]+$/, "")
});
return { success: true, path: files.length === 1 ? savePath : savePath.replace(/\.[^/.]+$/, "") };
} catch (error) {
console.error('Download error:', error);
sendToRenderer('download-error', error.message);
return { success: false, message: error.message };
} finally {
currentDownload = null;
if (tempDir && fs.existsSync(tempDir)) {
try {
await rm(tempDir, { recursive: true, force: true });
} catch (err) {
console.error('Temp cleanup error:', err);
}
}
}
});
// Handle cancel request
ipcMain.handle('cancel-download', async () => {
if (currentDownload) {
try {
currentDownload.kill();
currentDownload = null;
return { success: true };
} catch (err) {
return { success: false, message: err.message };
}
}
return { success: false, message: 'No active download' };
});