Files
VibeDownloader/utils/downloader.js
T

617 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { spawn, exec } = require('child_process');
const path = require('path');
const fs = require('fs');
const https = require('https');
const BIN_DIR = 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
};