621 lines
19 KiB
JavaScript
621 lines
19 KiB
JavaScript
const { spawn, exec } = require('child_process');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const https = require('https');
|
||
const { app } = require('electron');
|
||
|
||
const BIN_DIR = app && app.isPackaged
|
||
? path.join(process.resourcesPath, 'app.asar.unpacked', 'bin')
|
||
: path.join(__dirname, '..', 'bin');
|
||
|
||
const YTDLP_PATH = path.join(BIN_DIR, 'yt-dlp.exe');
|
||
const FFMPEG_PATH = path.join(BIN_DIR, 'ffmpeg.exe');
|
||
|
||
console.log('[DEBUG] Downloader Module initialized.');
|
||
console.log('[DEBUG] Binary directory path:', BIN_DIR);
|
||
console.log('[DEBUG] yt-dlp path:', YTDLP_PATH);
|
||
console.log('[DEBUG] ffmpeg path:', FFMPEG_PATH);
|
||
|
||
// Ensure binary directory exists
|
||
if (!fs.existsSync(BIN_DIR)) {
|
||
console.log('[DEBUG] Binary directory does not exist, creating:', BIN_DIR);
|
||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||
}
|
||
|
||
// Download helper function with progress callback
|
||
function downloadFile(url, dest, onProgress) {
|
||
console.log(`[DEBUG] Starting download for: ${url} -> Target: ${dest}`);
|
||
return new Promise((resolve, reject) => {
|
||
let file;
|
||
|
||
function makeRequest(requestUrl) {
|
||
console.log(`[DEBUG] Requesting URL: ${requestUrl}`);
|
||
https.get(requestUrl, (response) => {
|
||
console.log(`[DEBUG] Received status code: ${response.statusCode} for ${requestUrl}`);
|
||
|
||
// Follow HTTP redirects (301, 302, 307, 308)
|
||
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
||
const redirectUrl = response.headers.location;
|
||
console.log(`[DEBUG] Redirecting to: ${redirectUrl}`);
|
||
makeRequest(redirectUrl);
|
||
return;
|
||
}
|
||
|
||
if (response.statusCode !== 200) {
|
||
console.error(`[DEBUG] Error: Download failed with status ${response.statusCode}`);
|
||
reject(new Error(`Failed to download: status ${response.statusCode}`));
|
||
return;
|
||
}
|
||
|
||
console.log(`[DEBUG] Connection successful. Writing to disk: ${dest}`);
|
||
file = fs.createWriteStream(dest);
|
||
const totalSize = parseInt(response.headers['content-length'], 10);
|
||
console.log(`[DEBUG] Total file size: ${totalSize ? (totalSize / (1024 * 1024)).toFixed(2) + ' MB' : 'unknown'}`);
|
||
let downloaded = 0;
|
||
|
||
response.on('data', (chunk) => {
|
||
downloaded += chunk.length;
|
||
file.write(chunk);
|
||
if (onProgress && totalSize) {
|
||
const percent = Math.round((downloaded / totalSize) * 100);
|
||
onProgress(percent);
|
||
}
|
||
});
|
||
|
||
response.on('end', () => {
|
||
file.end();
|
||
console.log(`[DEBUG] File download completed successfully: ${dest}`);
|
||
resolve();
|
||
});
|
||
}).on('error', (err) => {
|
||
console.error(`[DEBUG] Network error during request:`, err);
|
||
if (file) {
|
||
fs.unlink(dest, () => {
|
||
console.log(`[DEBUG] Cleaned up partial file on error: ${dest}`);
|
||
});
|
||
}
|
||
reject(err);
|
||
});
|
||
}
|
||
|
||
makeRequest(url);
|
||
});
|
||
}
|
||
|
||
// Unzip using PowerShell (Windows native)
|
||
function unzipWindows(zipPath, destDir) {
|
||
console.log(`[DEBUG] Extracting archive: ${zipPath} to ${destDir}`);
|
||
return new Promise((resolve, reject) => {
|
||
const cmd = `powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`;
|
||
console.log(`[DEBUG] Executing extract command: ${cmd}`);
|
||
exec(cmd, (error) => {
|
||
if (error) {
|
||
console.error(`[DEBUG] Extraction failed:`, error);
|
||
reject(error);
|
||
} else {
|
||
console.log(`[DEBUG] Extraction completed successfully.`);
|
||
resolve();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Check and install dependencies
|
||
async function checkDependencies(onStatusUpdate) {
|
||
console.log('[DEBUG] Checking applications and libraries dependencies...');
|
||
|
||
let needsYtDlp = true;
|
||
if (fs.existsSync(YTDLP_PATH)) {
|
||
const stats = fs.statSync(YTDLP_PATH);
|
||
console.log(`[DEBUG] Found yt-dlp.exe. Size: ${(stats.size / (1024 * 1024)).toFixed(2)} MB`);
|
||
if (stats.size > 1024 * 1024) {
|
||
needsYtDlp = false;
|
||
console.log(`[DEBUG] yt-dlp.exe is valid.`);
|
||
} else {
|
||
console.log(`[DEBUG] yt-dlp.exe is corrupted or incomplete. Deleting to redownload...`);
|
||
try { fs.unlinkSync(YTDLP_PATH); } catch (e) { console.error('[DEBUG] Failed to delete corrupted yt-dlp:', e); }
|
||
}
|
||
} else {
|
||
console.log(`[DEBUG] yt-dlp.exe not found.`);
|
||
}
|
||
|
||
let needsFfmpeg = true;
|
||
if (fs.existsSync(FFMPEG_PATH)) {
|
||
const stats = fs.statSync(FFMPEG_PATH);
|
||
console.log(`[DEBUG] Found ffmpeg.exe. Size: ${(stats.size / (1024 * 1024)).toFixed(2)} MB`);
|
||
if (stats.size > 1024 * 1024) {
|
||
needsFfmpeg = false;
|
||
console.log(`[DEBUG] ffmpeg.exe is valid.`);
|
||
} else {
|
||
console.log(`[DEBUG] ffmpeg.exe is corrupted or incomplete. Deleting to redownload...`);
|
||
try { fs.unlinkSync(FFMPEG_PATH); } catch (e) { console.error('[DEBUG] Failed to delete corrupted ffmpeg:', e); }
|
||
}
|
||
} else {
|
||
console.log(`[DEBUG] ffmpeg.exe not found.`);
|
||
}
|
||
|
||
if (!needsYtDlp && !needsFfmpeg) {
|
||
console.log('[DEBUG] All dependencies are verified and ready.');
|
||
return true;
|
||
}
|
||
|
||
if (needsYtDlp) {
|
||
onStatusUpdate('Downloading yt-dlp...', 10);
|
||
const ytDlpUrl = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe';
|
||
await downloadFile(ytDlpUrl, YTDLP_PATH, (pct) => {
|
||
onStatusUpdate(`Downloading yt-dlp... ${pct}%`, Math.round(pct * 0.4));
|
||
});
|
||
}
|
||
|
||
if (needsFfmpeg) {
|
||
onStatusUpdate('Downloading ffmpeg...', 45);
|
||
const ffmpegZipUrl = 'https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.4.1/ffmpeg-4.4.1-win-64.zip';
|
||
const tempZip = path.join(BIN_DIR, 'ffmpeg.zip');
|
||
|
||
await downloadFile(ffmpegZipUrl, tempZip, (pct) => {
|
||
onStatusUpdate(`Downloading ffmpeg... ${pct}%`, 40 + Math.round(pct * 0.5));
|
||
});
|
||
|
||
onStatusUpdate('Extracting ffmpeg...', 95);
|
||
try {
|
||
await unzipWindows(tempZip, BIN_DIR);
|
||
} finally {
|
||
if (fs.existsSync(tempZip)) {
|
||
console.log(`[DEBUG] Cleaning up temporary archive: ${tempZip}`);
|
||
try { fs.unlinkSync(tempZip); } catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
onStatusUpdate('Dependencies ready!', 100);
|
||
console.log('[DEBUG] Dependencies installation completed successfully.');
|
||
return true;
|
||
}
|
||
|
||
function formatDuration(sec) {
|
||
if (!sec) return '';
|
||
const hrs = Math.floor(sec / 3600);
|
||
const mins = Math.floor((sec % 3600) / 60);
|
||
const secs = Math.floor(sec % 60);
|
||
if (hrs > 0) {
|
||
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||
}
|
||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
// Get video metadata (title, thumbnail, duration, etc.)
|
||
function getVideoInfo(url, browser) {
|
||
console.log(`[DEBUG] Fetching info for URL: ${url}`);
|
||
return new Promise((resolve, reject) => {
|
||
let args = ['-J', '--flat-playlist'];
|
||
if (browser) {
|
||
args.push('--cookies-from-browser', browser);
|
||
}
|
||
args.push(url);
|
||
|
||
console.log(`[DEBUG] Spawning process: ${YTDLP_PATH} ${args.join(' ')}`);
|
||
const proc = spawn(YTDLP_PATH, args);
|
||
let stdout = '';
|
||
let stderr = '';
|
||
|
||
proc.stdout.on('data', (data) => { stdout += data; });
|
||
proc.stderr.on('data', (data) => { stderr += data; });
|
||
|
||
proc.on('close', (code) => {
|
||
console.log(`[DEBUG] yt-dlp info process exited with code ${code}`);
|
||
if (code !== 0) {
|
||
console.error(`[DEBUG] yt-dlp error output:`, stderr);
|
||
reject(new Error(stderr || 'Failed to fetch details'));
|
||
return;
|
||
}
|
||
try {
|
||
const info = JSON.parse(stdout);
|
||
|
||
if (info._type === 'playlist' || info.entries) {
|
||
console.log(`[DEBUG] Playlist metadata fetched successfully for: "${info.title}" (${info.entries.length} items)`);
|
||
const entries = info.entries.map((entry, index) => {
|
||
let thumb = '';
|
||
if (entry.thumbnail) thumb = entry.thumbnail;
|
||
else if (entry.thumbnails && entry.thumbnails.length > 0) thumb = entry.thumbnails[0].url;
|
||
|
||
let durStr = '';
|
||
if (entry.duration_string) durStr = entry.duration_string;
|
||
else if (entry.duration) durStr = formatDuration(entry.duration);
|
||
|
||
return {
|
||
id: entry.id || `track-${index}`,
|
||
title: entry.title || `Track ${index + 1}`,
|
||
thumbnail: thumb,
|
||
duration: durStr,
|
||
uploader: entry.uploader || info.uploader || 'Playlist',
|
||
url: entry.url || `https://www.youtube.com/watch?v=${entry.id}`
|
||
};
|
||
});
|
||
|
||
resolve({
|
||
isPlaylist: true,
|
||
playlistTitle: info.title,
|
||
entries: entries
|
||
});
|
||
} else {
|
||
console.log(`[DEBUG] Single video metadata fetched successfully for: "${info.title}"`);
|
||
let thumb = '';
|
||
if (info.thumbnail) thumb = info.thumbnail;
|
||
else if (info.thumbnails && info.thumbnails.length > 0) thumb = info.thumbnails[0].url;
|
||
|
||
resolve({
|
||
isPlaylist: false,
|
||
entries: [{
|
||
id: info.id || 'video',
|
||
title: info.title,
|
||
thumbnail: thumb,
|
||
duration: info.duration_string || (info.duration ? formatDuration(info.duration) : ''),
|
||
uploader: info.uploader || 'Bilinmiyor',
|
||
url: url
|
||
}]
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error(`[DEBUG] JSON parsing error on yt-dlp output:`, err);
|
||
reject(err);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Download single item helper
|
||
function downloadSingle(url, format, quality, outputDir, browser, onProgress) {
|
||
return new Promise((resolve, reject) => {
|
||
let args = [];
|
||
const outputTemplate = path.join(outputDir, '%(title)s.%(ext)s');
|
||
const startTime = Date.now();
|
||
let totalSize = 'Bilinmiyor';
|
||
|
||
if (format === 'mp3') {
|
||
const q = quality ? `${quality}K` : '320K';
|
||
args = [
|
||
'--no-playlist',
|
||
'--ffmpeg-location', FFMPEG_PATH,
|
||
'-x',
|
||
'--audio-format', 'mp3',
|
||
'--audio-quality', q,
|
||
'-o', outputTemplate
|
||
];
|
||
} else {
|
||
const res = quality || '1080';
|
||
args = [
|
||
'--no-playlist',
|
||
'--ffmpeg-location', FFMPEG_PATH,
|
||
'-f', `bv*[height<=${res}][ext=mp4]+ba[ext=m4a]/b[height<=${res}][ext=mp4]/bv*[height<=${res}]+ba/b[height<=${res}]/bestvideo+bestaudio/best`,
|
||
'-o', outputTemplate
|
||
];
|
||
}
|
||
|
||
if (browser) {
|
||
args.push('--cookies-from-browser', browser);
|
||
}
|
||
args.push(url);
|
||
|
||
console.log(`[DEBUG] Spawning single downloader process: ${YTDLP_PATH} ${args.join(' ')}`);
|
||
const proc = spawn(YTDLP_PATH, args);
|
||
|
||
proc.stdout.on('data', (data) => {
|
||
const line = data.toString();
|
||
|
||
const sizeMatch = line.match(/of\s+(\d+(?:\.\d+)?\s*[a-zA-Z]+)/);
|
||
if (sizeMatch) {
|
||
totalSize = sizeMatch[1];
|
||
}
|
||
|
||
const match = line.match(/\[download\]\s+(\d+\.\d+)%/);
|
||
if (match && onProgress) {
|
||
const percent = parseFloat(match[1]);
|
||
onProgress(percent, `İndiriliyor... %${percent}`);
|
||
} else if (line.includes('[ExtractAudio]') || line.includes('[Merger]')) {
|
||
onProgress(95, 'İşleniyor/Dönüştürülüyor...');
|
||
}
|
||
});
|
||
|
||
proc.stderr.on('data', (data) => {
|
||
console.warn(`[DEBUG] [yt-dlp stderr]:`, data.toString());
|
||
});
|
||
|
||
proc.on('close', (code) => {
|
||
console.log(`[DEBUG] Single downloader process exited with code ${code}`);
|
||
if (code === 0) {
|
||
const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
resolve({
|
||
size: totalSize,
|
||
duration: `${durationSec} saniye`
|
||
});
|
||
} else {
|
||
reject(new Error(`Download exited with code ${code}`));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Module-level Queue State variables
|
||
let currentProcess = null;
|
||
let isPaused = false;
|
||
let isStopped = false;
|
||
let currentItemIndex = 0;
|
||
let downloadQueue = [];
|
||
let queueOutputDir = '';
|
||
let queueBrowser = null;
|
||
let queueOnProgressCallback = null;
|
||
let queueResults = [];
|
||
let queueStartTime = 0;
|
||
let activeResolve = null;
|
||
let activeReject = null;
|
||
|
||
function getQueueStats() {
|
||
const durationSec = ((Date.now() - queueStartTime) / 1000).toFixed(1);
|
||
const completedCount = queueResults.filter(r => r.success).length;
|
||
return {
|
||
totalItems: downloadQueue.length,
|
||
completed: completedCount,
|
||
duration: `${durationSec} saniye`,
|
||
outputDir: queueOutputDir,
|
||
results: queueResults
|
||
};
|
||
}
|
||
|
||
function downloadSingleItemWithProcessRef(item) {
|
||
return new Promise((resolve, reject) => {
|
||
let args = [];
|
||
const outputTemplate = path.join(queueOutputDir, '%(title)s.%(ext)s');
|
||
const startTime = Date.now();
|
||
let totalSize = 'Bilinmiyor';
|
||
|
||
if (item.format === 'mp3') {
|
||
const q = item.quality ? `${item.quality}K` : '320K';
|
||
args = [
|
||
'--no-playlist',
|
||
'--ffmpeg-location', FFMPEG_PATH,
|
||
'-x',
|
||
'--audio-format', 'mp3',
|
||
'--audio-quality', q,
|
||
'-o', outputTemplate
|
||
];
|
||
} else {
|
||
const res = item.quality || '1080';
|
||
args = [
|
||
'--no-playlist',
|
||
'--ffmpeg-location', FFMPEG_PATH,
|
||
'-f', `bv*[height<=${res}][ext=mp4]+ba[ext=m4a]/b[height<=${res}][ext=mp4]/bv*[height<=${res}]+ba/b[height<=${res}]/bestvideo+bestaudio/best`,
|
||
'-o', outputTemplate
|
||
];
|
||
}
|
||
|
||
if (queueBrowser) {
|
||
args.push('--cookies-from-browser', queueBrowser);
|
||
}
|
||
args.push(item.url);
|
||
|
||
console.log(`[DEBUG] Spawning single downloader process: ${YTDLP_PATH} ${args.join(' ')}`);
|
||
currentProcess = spawn(YTDLP_PATH, args);
|
||
|
||
currentProcess.stdout.on('data', (data) => {
|
||
const line = data.toString();
|
||
|
||
const sizeMatch = line.match(/of\s+(\d+(?:\.\d+)?\s*[a-zA-Z]+)/);
|
||
if (sizeMatch) {
|
||
totalSize = sizeMatch[1];
|
||
}
|
||
|
||
const match = line.match(/\[download\]\s+(\d+\.\d+)%/);
|
||
if (match) {
|
||
const percent = parseFloat(match[1]);
|
||
queueOnProgressCallback({
|
||
itemId: item.id,
|
||
index: currentItemIndex,
|
||
total: downloadQueue.length,
|
||
percent: percent,
|
||
status: `İndiriliyor...`,
|
||
title: item.title
|
||
});
|
||
} else if (line.includes('[ExtractAudio]') || line.includes('[Merger]')) {
|
||
queueOnProgressCallback({
|
||
itemId: item.id,
|
||
index: currentItemIndex,
|
||
total: downloadQueue.length,
|
||
percent: 95,
|
||
status: `İşleniyor/Dönüştürülüyor...`,
|
||
title: item.title
|
||
});
|
||
}
|
||
});
|
||
|
||
currentProcess.stderr.on('data', (data) => {
|
||
console.warn(`[DEBUG] [yt-dlp stderr]:`, data.toString());
|
||
});
|
||
|
||
currentProcess.on('close', (code) => {
|
||
console.log(`[DEBUG] Single downloader process exited with code ${code}`);
|
||
currentProcess = null;
|
||
if (code === 0) {
|
||
const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
resolve({
|
||
size: totalSize,
|
||
duration: `${durationSec} saniye`
|
||
});
|
||
} else {
|
||
reject(new Error(`Download exited with code ${code}`));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function processQueue() {
|
||
if (isStopped) {
|
||
activeResolve({
|
||
success: false,
|
||
stopped: true,
|
||
stats: getQueueStats()
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (isPaused) {
|
||
queueOnProgressCallback({
|
||
itemId: downloadQueue[currentItemIndex].id,
|
||
index: currentItemIndex,
|
||
total: downloadQueue.length,
|
||
percent: 0,
|
||
status: `Duraklatıldı`,
|
||
title: downloadQueue[currentItemIndex].title
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (currentItemIndex >= downloadQueue.length) {
|
||
activeResolve({
|
||
success: true,
|
||
stats: getQueueStats()
|
||
});
|
||
return;
|
||
}
|
||
|
||
const item = downloadQueue[currentItemIndex];
|
||
console.log(`[DEBUG] Queue processing [${currentItemIndex+1}/${downloadQueue.length}]: "${item.title}"`);
|
||
|
||
queueOnProgressCallback({
|
||
itemId: item.id,
|
||
index: currentItemIndex,
|
||
total: downloadQueue.length,
|
||
percent: 0,
|
||
status: `İndiriliyor...`,
|
||
title: item.title
|
||
});
|
||
|
||
try {
|
||
const result = await downloadSingleItemWithProcessRef(item);
|
||
|
||
queueResults.push({
|
||
success: true,
|
||
item,
|
||
size: result.size,
|
||
duration: result.duration
|
||
});
|
||
|
||
queueOnProgressCallback({
|
||
itemId: item.id,
|
||
index: currentItemIndex,
|
||
total: downloadQueue.length,
|
||
percent: 100,
|
||
status: `Tamamlandı`,
|
||
title: item.title
|
||
});
|
||
|
||
currentItemIndex++;
|
||
processQueue(); // Process next item
|
||
|
||
} catch (error) {
|
||
if (isStopped) {
|
||
console.log('[DEBUG] Process aborted by user stop command.');
|
||
activeResolve({
|
||
success: false,
|
||
stopped: true,
|
||
stats: getQueueStats()
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (isPaused) {
|
||
console.log('[DEBUG] Process suspended by user pause command.');
|
||
return;
|
||
}
|
||
|
||
console.error(`[DEBUG] Queue error on item "${item.title}":`, error);
|
||
queueResults.push({
|
||
success: false,
|
||
item,
|
||
error: error.message
|
||
});
|
||
|
||
queueOnProgressCallback({
|
||
itemId: item.id,
|
||
index: currentItemIndex,
|
||
total: downloadQueue.length,
|
||
percent: 0,
|
||
status: `Hata`,
|
||
title: item.title,
|
||
failed: true
|
||
});
|
||
|
||
currentItemIndex++;
|
||
processQueue(); // Continue queue on item error
|
||
}
|
||
}
|
||
|
||
// Download list of items sequentially
|
||
async function downloadMedia(items, outputDir, browser, onProgress) {
|
||
return new Promise((resolve, reject) => {
|
||
console.log(`[DEBUG] Media download requested for ${items.length} items.`);
|
||
|
||
downloadQueue = items;
|
||
queueOutputDir = outputDir;
|
||
queueBrowser = browser;
|
||
queueOnProgressCallback = onProgress;
|
||
isPaused = false;
|
||
isStopped = false;
|
||
currentItemIndex = 0;
|
||
queueResults = [];
|
||
queueStartTime = Date.now();
|
||
activeResolve = resolve;
|
||
activeReject = reject;
|
||
|
||
processQueue();
|
||
});
|
||
}
|
||
|
||
function pauseDownload() {
|
||
console.log('[DEBUG] Pause request received.');
|
||
isPaused = true;
|
||
if (currentProcess) {
|
||
try {
|
||
currentProcess.kill('SIGINT');
|
||
} catch (e) {
|
||
console.error('[DEBUG] Failed to interrupt process on pause:', e);
|
||
}
|
||
}
|
||
return { success: true };
|
||
}
|
||
|
||
function resumeDownload() {
|
||
console.log('[DEBUG] Resume request received.');
|
||
if (!isPaused) return { success: false, error: 'Not paused' };
|
||
isPaused = false;
|
||
processQueue();
|
||
return { success: true };
|
||
}
|
||
|
||
function stopDownload() {
|
||
console.log('[DEBUG] Stop request received.');
|
||
isStopped = true;
|
||
if (currentProcess) {
|
||
try {
|
||
currentProcess.kill('SIGINT');
|
||
} catch (e) {
|
||
console.error('[DEBUG] Failed to interrupt process on stop:', e);
|
||
}
|
||
} else if (activeResolve) {
|
||
activeResolve({
|
||
success: false,
|
||
stopped: true,
|
||
stats: getQueueStats()
|
||
});
|
||
}
|
||
return { success: true };
|
||
}
|
||
|
||
module.exports = {
|
||
checkDependencies,
|
||
getVideoInfo,
|
||
downloadMedia,
|
||
pauseDownload,
|
||
resumeDownload,
|
||
stopDownload
|
||
};
|