412 lines
16 KiB
JavaScript
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' };
|
|
}); |