| const express = require('express'); |
| const cors = require('cors'); |
| const path = require('path'); |
| const fs = require('fs'); |
| const { spawn, exec } = require('child_process'); |
|
|
| const app = express(); |
| const PORT = process.env.PORT || 3000; |
|
|
| |
| app.use((req, res, next) => { |
| res.setHeader('X-Content-Type-Options', 'nosniff'); |
| res.setHeader('X-Frame-Options', 'SAMEORIGIN'); |
| res.setHeader('X-XSS-Protection', '1; mode=block'); |
| res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); |
| next(); |
| }); |
|
|
| |
| app.use(cors({ |
| origin: '*', |
| methods: ['GET', 'POST'], |
| credentials: false |
| })); |
| app.use(express.json({ limit: '1mb' })); |
| app.use(express.static('public')); |
|
|
| const DOWNLOADS_DIR = path.join(__dirname, 'downloads'); |
| const YT_DLP_BIN = path.join(__dirname, 'node_modules', 'youtube-dl-exec', 'bin', 'yt-dlp'); |
| const MAX_TITLE_LENGTH = 100; |
| const CLEANUP_DELAY = 5000; |
|
|
| |
| const CONTENT_TYPE_VIDEO = 'video/mp4'; |
| const CONTENT_TYPE_AUDIO = 'audio/mpeg'; |
| const HEADER_CONTENT_DISPOSITION = 'Content-Disposition'; |
| const HEADER_CACHE_CONTROL = 'Cache-Control'; |
|
|
| |
| const YOUTUBE_BASE_URL = 'https://www.youtube.com'; |
| const YOUTUBE_PLAYLIST_URL = `${YOUTUBE_BASE_URL}/playlist?list=`; |
| const YOUTUBE_DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg'; |
|
|
| if (!fs.existsSync(DOWNLOADS_DIR)) { |
| fs.mkdirSync(DOWNLOADS_DIR, { recursive: true }); |
| } |
|
|
| function getVideoId(url) { |
| if (!url || typeof url !== 'string') { |
| return null; |
| } |
| |
| const patterns = [ |
| /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/, |
| /^[a-zA-Z0-9_-]{11}$/ |
| ]; |
| |
| for (const pattern of patterns) { |
| const match = url.match(pattern); |
| if (match) { |
| return match[1] || match[0]; |
| } |
| } |
| return null; |
| } |
|
|
| function getPlaylistId(url) { |
| if (!url || typeof url !== 'string') { |
| return null; |
| } |
| const match = url.match(/list=([a-zA-Z0-9_-]+)/); |
| return match ? match[1] : null; |
| } |
|
|
| function sanitizeFilename(title) { |
| if (!title || typeof title !== 'string') { |
| return 'video'; |
| } |
| |
| return title |
| .trim() |
| .replace(/[<>:"/\\|?*\x00-\x1F]/g, '') |
| .replace(/\s+/g, '_') |
| .replace(/_{2,}/g, '_') |
| .substring(0, MAX_TITLE_LENGTH); |
| } |
|
|
| function cleanupFile(filepath) { |
| try { |
| if (fs.existsSync(filepath)) { |
| fs.unlinkSync(filepath); |
| } |
| } catch (error) { |
| console.error(`Cleanup failed for ${filepath}:`, error.message); |
| } |
| } |
|
|
| function execYtdl(args) { |
| return new Promise((resolve, reject) => { |
| if (!Array.isArray(args) || args.length === 0) { |
| return reject(new Error('Invalid arguments')); |
| } |
| |
| const proc = spawn(YT_DLP_BIN, args, { stdio: 'pipe' }); |
| let stdout = ''; |
| let stderr = ''; |
| |
| proc.stdout.on('data', (data) => { stdout += data; }); |
| proc.stderr.on('data', (data) => { stderr += data; }); |
| |
| proc.on('close', (code) => { |
| if (code === 0) { |
| resolve(stdout); |
| } else { |
| reject(new Error(stderr || `Command failed with code ${code}`)); |
| } |
| }); |
| |
| proc.on('error', reject); |
| }); |
| } |
|
|
| app.get('/', (_req, res) => { |
| res.sendFile(path.join(__dirname, 'public', 'index.html')); |
| }); |
|
|
| app.post('/api/info', async (req, res) => { |
| try { |
| const { url } = req.body; |
| |
| if (!url || typeof url !== 'string') { |
| return res.status(400).json({ error: 'URL is required' }); |
| } |
| |
| const videoId = getVideoId(url); |
| const playlistId = getPlaylistId(url); |
|
|
| if (!videoId && !playlistId) { |
| return res.status(400).json({ error: 'Please enter a valid YouTube URL' }); |
| } |
|
|
| if (playlistId) { |
| const output = await execYtdl([ |
| '--flat-playlist', |
| '--print', '%(title)s|%(id)s|%(duration)s', |
| `${YOUTUBE_PLAYLIST_URL}${playlistId}` |
| ]); |
| |
| const lines = output.trim().split('\n').filter(l => l); |
| const videos = lines.slice(0, 10).map(line => { |
| const parts = line.split('|'); |
| return { |
| id: parts[1] || '', |
| title: parts[0] || 'Unknown', |
| duration: parts[2] || '0' |
| }; |
| }); |
|
|
| return res.json({ |
| title: `Playlist (${lines.length} videos)`, |
| isPlaylist: true, |
| playlistCount: lines.length, |
| videos: videos, |
| thumbnail: YOUTUBE_DEFAULT_THUMBNAIL |
| }); |
| } |
|
|
| const jsonOutput = await execYtdl(['--dump-json', '--no-playlist', url]); |
| const videoData = JSON.parse(jsonOutput); |
|
|
| |
| const qualities = []; |
| const seenHeights = new Set(); |
| |
| |
| if (videoData.formats && Array.isArray(videoData.formats)) { |
| videoData.formats.forEach(format => { |
| |
| if (format.height && format.vcodec && format.vcodec !== 'none') { |
| const height = parseInt(format.height, 10); |
| |
| |
| if (seenHeights.has(height)) return; |
| |
| |
| if (height >= 144 && height <= 4320) { |
| seenHeights.add(height); |
| qualities.push({ |
| formatId: format.format_id, |
| resolution: `${format.width || '?'}x${format.height}`, |
| height: height, |
| ext: format.ext || 'mp4', |
| vcodec: format.vcodec, |
| fps: format.fps || 30 |
| }); |
| } |
| } |
| }); |
| } |
| |
| |
| qualities.sort((a, b) => b.height - a.height); |
|
|
| res.json({ |
| title: videoData.title || 'Unknown', |
| thumbnail: videoData.thumbnail || '', |
| duration: videoData.duration || 0, |
| uploader: videoData.uploader || videoData.channel || 'Unknown', |
| isPlaylist: false, |
| qualities: qualities |
| }); |
|
|
| } catch (error) { |
| res.status(400).json({ error: `Failed to fetch video info: ${error.message}` }); |
| } |
| }); |
|
|
| app.post('/api/download/video', async (req, res) => { |
| try { |
| const { url, quality } = req.body; |
| |
| if (!url || typeof url !== 'string') { |
| return res.status(400).json({ error: 'URL is required' }); |
| } |
| |
| if (!quality || typeof quality !== 'string') { |
| return res.status(400).json({ error: 'Quality format is required' }); |
| } |
| |
| if (!getVideoId(url)) { |
| return res.status(400).json({ error: 'Invalid YouTube URL' }); |
| } |
|
|
| const jsonOutput = await execYtdl(['--dump-json', '--no-playlist', url]); |
| const videoData = JSON.parse(jsonOutput); |
| const title = sanitizeFilename(videoData.title); |
|
|
| const existingFiles = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.startsWith(title)); |
| existingFiles.forEach(f => cleanupFile(path.join(DOWNLOADS_DIR, f))); |
|
|
| const outputPath = path.join(DOWNLOADS_DIR, `${title}.mp4`); |
|
|
| |
| const selectedFormat = videoData.formats?.find(f => f.format_id === quality); |
| if (!selectedFormat) { |
| return res.status(400).json({ error: 'Selected quality format not available' }); |
| } |
|
|
| |
| |
| |
| const formatString = `${quality}+bestaudio/best`; |
| |
| await execYtdl([ |
| '-f', formatString, |
| '--merge-output-format', 'mp4', |
| '--no-check-certificate', |
| '--no-playlist', |
| '--no-warnings', |
| '-o', outputPath, |
| url |
| ]); |
|
|
| |
| let finalFile = ''; |
| let finalPath = ''; |
| |
| const downloadedFiles = fs.readdirSync(DOWNLOADS_DIR).filter(f => |
| !f.endsWith('.part') && |
| !f.endsWith('.temp') && |
| !f.endsWith('.ytdl') && |
| f.includes(title) |
| ); |
| |
| |
| for (const f of downloadedFiles) { |
| const filePath = path.join(DOWNLOADS_DIR, f); |
| const ext = path.extname(f).toLowerCase(); |
| |
| |
| if (!f.includes('.f') && (ext === '.mp4' || ext === '.mkv' || ext === '.webm')) { |
| finalFile = f; |
| finalPath = filePath; |
| break; |
| } |
| } |
| |
| |
| if (!finalPath) { |
| for (const f of downloadedFiles) { |
| if (f.includes(`.f${quality}.`)) { |
| const filePath = path.join(DOWNLOADS_DIR, f); |
| const ext = path.extname(f).toLowerCase(); |
| |
| if (ext === '.mp4' || ext === '.mkv' || ext === '.webm') { |
| |
| const cleanName = `${title}.mp4`; |
| const newPath = path.join(DOWNLOADS_DIR, cleanName); |
| if (!fs.existsSync(newPath)) { |
| fs.renameSync(filePath, newPath); |
| } |
| finalFile = cleanName; |
| finalPath = newPath; |
| |
| |
| downloadedFiles.forEach(df => { |
| if (df.includes('.f') && df !== f) { |
| cleanupFile(path.join(DOWNLOADS_DIR, df)); |
| } |
| }); |
| break; |
| } |
| } |
| } |
| } |
|
|
| if (!finalPath || !fs.existsSync(finalPath)) { |
| return res.status(400).json({ error: 'Download failed - file not found. Please ensure ffmpeg is installed for high-quality downloads.' }); |
| } |
|
|
| res.setHeader(HEADER_CONTENT_DISPOSITION, `attachment; filename="${finalFile}"`); |
| res.setHeader('Content-Type', CONTENT_TYPE_VIDEO); |
| res.setHeader(HEADER_CACHE_CONTROL, 'no-cache'); |
| |
| const fileStream = fs.createReadStream(finalPath); |
| |
| fileStream.on('error', () => { |
| cleanupFile(finalPath); |
| if (!res.headersSent) { |
| res.status(500).json({ error: 'Stream error occurred' }); |
| } |
| }); |
| |
| fileStream.on('close', () => { |
| cleanupFile(finalPath); |
| |
| const remainingFiles = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.includes(title) && f.includes('.f')); |
| remainingFiles.forEach(f => cleanupFile(path.join(DOWNLOADS_DIR, f))); |
| }); |
| |
| fileStream.pipe(res); |
|
|
| } catch (error) { |
| res.status(400).json({ error: `Download failed: ${error.message}` }); |
| } |
| }); |
|
|
| app.post('/api/download/audio', async (req, res) => { |
| try { |
| const { url } = req.body; |
| |
| if (!url || typeof url !== 'string') { |
| return res.status(400).json({ error: 'URL is required' }); |
| } |
| |
| if (!getVideoId(url)) { |
| return res.status(400).json({ error: 'Invalid YouTube URL' }); |
| } |
|
|
| const jsonOutput = await execYtdl(['--dump-json', '--no-playlist', url]); |
| const videoData = JSON.parse(jsonOutput); |
| const title = sanitizeFilename(videoData.title); |
|
|
| const filename = `${title}.mp3`; |
| const filepath = path.join(DOWNLOADS_DIR, filename); |
|
|
| try { |
| await execYtdl([ |
| '-x', '--audio-format', 'mp3', '--audio-quality', '0', |
| '--no-check-certificate', |
| '--no-playlist', |
| '--no-warnings', |
| '--merge-output-format', 'mp3', |
| '-o', filepath, |
| url |
| ]); |
| } catch (conversionError) { |
| |
| await execYtdl([ |
| '-f', 'bestaudio', |
| '--no-check-certificate', |
| '--no-playlist', |
| '--no-warnings', |
| '-o', filepath, |
| url |
| ]); |
| } |
|
|
| const files = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.startsWith(title) && !f.endsWith('.part')); |
| |
| if (files.length === 0) { |
| return res.status(400).json({ error: 'Audio download failed' }); |
| } |
|
|
| let actualFile = files[0]; |
| let actualPath = path.join(DOWNLOADS_DIR, actualFile); |
|
|
| const mp3Filename = `${title}.mp3`; |
| const mp3Path = path.join(DOWNLOADS_DIR, mp3Filename); |
| |
| if (!actualFile.endsWith('.mp3')) { |
| if (!fs.existsSync(mp3Path)) { |
| fs.renameSync(actualPath, mp3Path); |
| actualFile = mp3Filename; |
| actualPath = mp3Path; |
| } |
| } |
|
|
| res.setHeader(HEADER_CONTENT_DISPOSITION, `attachment; filename="${actualFile}"`); |
| res.setHeader('Content-Type', CONTENT_TYPE_AUDIO); |
| res.setHeader(HEADER_CACHE_CONTROL, 'no-cache'); |
| |
| res.download(actualPath, actualFile, (err) => { |
| if (err && !res.headersSent) { |
| res.status(500).json({ error: 'Download error occurred' }); |
| } |
| setTimeout(() => cleanupFile(actualPath), CLEANUP_DELAY); |
| }); |
|
|
| } catch (error) { |
| res.status(400).json({ error: `Audio download failed: ${error.message}` }); |
| } |
| }); |
|
|
| app.post('/api/download/playlist', async (req, res) => { |
| try { |
| const { url, quality } = req.body; |
| |
| if (!url || typeof url !== 'string') { |
| return res.status(400).json({ error: 'URL is required' }); |
| } |
| |
| if (!quality || typeof quality !== 'string') { |
| return res.status(400).json({ error: 'Quality format is required' }); |
| } |
| |
| const playlistId = getPlaylistId(url); |
| |
| if (!playlistId) { |
| return res.status(400).json({ error: 'Invalid playlist URL' }); |
| } |
|
|
| |
| const formatStr = `${quality}+bestaudio/best`; |
|
|
| await execYtdl([ |
| '-f', formatStr, |
| '--merge-output-format', 'mp4', |
| '--yes-playlist', |
| '--no-check-certificate', |
| '--no-warnings', |
| '--recode-video', 'mp4', |
| '-o', path.join(DOWNLOADS_DIR, '%(title)s.%(ext)s'), |
| `${YOUTUBE_PLAYLIST_URL}${playlistId}` |
| ]); |
|
|
| const files = fs.readdirSync(DOWNLOADS_DIR).filter(f => !f.endsWith('.part') && !f.startsWith('temp_')); |
| |
| res.json({ |
| success: true, |
| count: files.length, |
| message: `Downloaded ${files.length} videos in high quality` |
| }); |
|
|
| } catch (error) { |
| res.status(400).json({ error: `Playlist download failed: ${error.message}` }); |
| } |
| }); |
|
|
| app.listen(PORT, '0.0.0.0', () => { |
| exec('ffmpeg -version', (error) => { |
| const separator = '='.repeat(60); |
| if (error) { |
| console.warn(`\n${separator}`); |
| console.warn('WARNING: FFmpeg NOT FOUND!'); |
| console.warn(separator); |
| console.warn('High-quality downloads (720p+) will NOT work correctly.'); |
| console.warn('Users will get low-quality videos instead of HD/4K.'); |
| console.warn(''); |
| console.warn('SOLUTION: Install FFmpeg'); |
| console.warn('Ubuntu/Debian: sudo apt install ffmpeg -y'); |
| console.warn('macOS: brew install ffmpeg'); |
| console.warn(`${separator}\n`); |
| } else { |
| console.info('FFmpeg detected - High-quality downloads enabled'); |
| } |
| console.info(`YouTube Downloader running on port ${PORT}`); |
| }); |
| }); |
|
|