import express, { Request, Response } from 'express'; import compression from 'compression'; import path from 'path'; import fs from 'fs'; import ffmpeg from 'fluent-ffmpeg'; // Configure ffmpeg paths ffmpeg.setFfmpegPath('/usr/bin/ffmpeg'); ffmpeg.setFfprobePath('/usr/bin/ffprobe'); import { Database } from './database'; import { SoundCloudService } from './soundcloud'; import { QuixoticBot } from './bot'; import { logger } from './logger'; const app = express(); const port = process.env.PORT || 3000; // Initialize services const db = new Database(); const soundcloud = new SoundCloudService(); // Middleware app.use(compression()); // Enable gzip compression app.use(express.json()); app.use((req: Request, res: Response, next) => { res.set('Content-Security-Policy', 'default-src \'self\'; ' + 'script-src \'self\' https://telegram.org \'unsafe-inline\'; ' + 'style-src \'self\' \'unsafe-inline\'; ' + 'img-src \'self\' data: https:; ' + 'font-src \'self\'; ' + 'connect-src \'self\' https://telegram.org; ' + 'frame-ancestors \'self\'; ' + 'base-uri \'self\'; ' + 'form-action \'self\'' ); res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); res.set('Cross-Origin-Opener-Policy', 'same-origin'); res.set('X-Frame-Options', 'SAMEORIGIN'); res.set('X-Content-Type-Options', 'nosniff'); res.set('Referrer-Policy', 'strict-origin-when-cross-origin'); next(); }); // Optimized caching strategy app.use(express.static('public', { maxAge: '365d', // Cache static assets for 1 year by default etag: true, lastModified: true, setHeaders: (res: Response, filePath: string) => { // Cache images, fonts, etc. with immutable flag if (filePath.match(/\.(jpg|jpeg|png|gif|ico|woff|woff2|ttf|eot|svg)$/)) { res.set('Cache-Control', 'public, max-age=31536000, immutable'); } // Cache CSS and JS with version string for 1 year else if (filePath.match(/\.(css|js)$/)) { res.set('Cache-Control', 'public, max-age=31536000, immutable'); // 1 year } // HTML files - short cache with revalidation else if (filePath.match(/\.html$/)) { res.set('Cache-Control', 'public, max-age=0, must-revalidate'); } } })); // Ensure downloads directory exists const downloadsDir = path.join(__dirname, '../downloads'); if (!fs.existsSync(downloadsDir)) { fs.mkdirSync(downloadsDir, { recursive: true }); } // Load version for cache busting let appVersion = Date.now().toString(); try { const versionPath = path.join(__dirname, '../public/version.json'); if (fs.existsSync(versionPath)) { const versionData = JSON.parse(fs.readFileSync(versionPath, 'utf8')); appVersion = versionData.version || appVersion; logger.info(`App version loaded: ${appVersion}`); } } catch (error) { logger.warn('Could not load version file, using timestamp'); } // Routes app.get('/', (req: Request, res: Response) => { // Use minified HTML in production const isProduction = process.env.NODE_ENV === 'production'; const htmlFile = isProduction ? 'index.min.html' : 'index.html'; const indexPath = path.join(__dirname, '../public', htmlFile); // Set cache headers for HTML (no cache for HTML itself) res.set({ 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }); // Read HTML and inject version try { let html = fs.readFileSync(indexPath, 'utf8'); // Replace version placeholders with actual version html = html.replace(/\?v=\d+/g, `?v=${appVersion}`); res.send(html); } catch (error) { logger.error('Error serving HTML:', error); res.sendFile(indexPath); } }); // Search videos app.post('/api/search', async (req: Request, res: Response) => { try { const { query, userId, page }: { query?: string; userId?: string; page?: number } = req.body; if (!query || query.trim().length === 0) { return res.status(400).json({ error: 'Query is required' }); } // Calculate offset based on page number (10 results per page) const currentPage = page || 1; const resultsPerPage = 10; const offset = (currentPage - 1) * resultsPerPage; // Save search history if (userId && userId !== 'demo') { try { const user = await db.getUserByTelegramId(userId); if (user) { await db.addSearchHistory(user.id, query); } } catch (dbError) { logger.error('Database error:', dbError); } } const videos = await soundcloud.searchTracks(query.trim(), resultsPerPage, offset); // Return hasMore flag based on results const hasMore = videos.length === resultsPerPage; res.json({ videos, hasMore }); } catch (error) { logger.error('Search error:', error); res.status(500).json({ error: 'Failed to search videos' }); } }); // Convert video to MP3 app.post('/api/convert', async (req: Request, res: Response) => { try { const { videoId, title, userId, url }: { videoId?: string; title?: string; userId?: string; url?: string } = req.body; logger.info(`Convert request received: ${title} (ID: ${videoId})`); if (!videoId) { return res.status(400).json({ error: 'Video ID is required' }); } // Generate safe filename const safeTitle = (title || '').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 50); const filename = `${videoId}_${safeTitle}.mp3`; const outputPath = path.join(downloadsDir, filename); // Check if file already exists if (fs.existsSync(outputPath)) { logger.info('File already exists, serving cached version'); const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`; return res.json({ audioUrl, title }); } logger.info(`Starting MP3 conversion: ${title}`); try { // Get audio stream from YouTube logger.debug(`Attempting to get audio stream for: ${videoId}`); const audioStream = await soundcloud.getAudioStream(videoId, url); logger.info('Audio stream obtained, starting FFmpeg conversion...'); // Download to temporary file first, then convert const tempInputPath = path.join(downloadsDir, `temp_${videoId}.tmp`); // Save stream to temporary file await new Promise((resolve, reject) => { const writeStream = fs.createWriteStream(tempInputPath); audioStream.pipe(writeStream); audioStream.on('end', resolve); audioStream.on('error', reject); writeStream.on('error', reject); }); logger.info('Temporary file saved, starting FFmpeg conversion...'); // Debug: check temp file const stats = fs.statSync(tempInputPath); logger.debug(`Temp file size: ${stats.size} bytes`); // Test ffmpeg with simple command first try { const { execSync } = require('child_process'); execSync(`ffmpeg -i "${tempInputPath}" -t 1 -f null -`, { encoding: 'utf8', stdio: 'pipe' }); logger.debug('FFmpeg file test passed'); } catch (e: any) { logger.error('FFmpeg file test failed:', e.stderr || e.message); } // Convert temporary file to MP3 using ffmpeg await new Promise((resolve, reject) => { const conversion = ffmpeg(tempInputPath) .audioCodec('libmp3lame') .audioBitrate('192k') .audioChannels(2) .audioFrequency(44100) .format('mp3') .output(outputPath) .on('start', (command: string) => { logger.ffmpeg('Started', command); }) .on('progress', (progress: any) => { if (progress.percent) { logger.ffmpeg('Progress', `${Math.round(progress.percent)}%`); } }) .on('end', () => { logger.success('MP3 conversion completed successfully'); // Clean up temporary file fs.unlink(tempInputPath, (err) => { if (err) logger.error('Failed to delete temp file:', err); }); resolve(); }) .on('error', (err: Error) => { logger.error('FFmpeg error:', err.message); // Clean up temporary file on error fs.unlink(tempInputPath, () => {}); reject(err); }); conversion.run(); }); // Save download record if (userId && userId !== 'demo') { try { const user = await db.getUserByTelegramId(userId); if (user) { await db.addDownload(user.id, videoId, title || '', outputPath); } } catch (dbError) { logger.error('Database error:', dbError); } } const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`; logger.success(`Conversion successful: ${audioUrl}`); res.json({ audioUrl, title }); } catch (conversionError: any) { logger.error(`Conversion failed for video: ${videoId}`); logger.error('Error details:', conversionError.message); logger.error('Full error:', conversionError); // Return error - no fallbacks for Telegram bot return res.status(503).json({ error: 'MP3 conversion failed. This video may be restricted or unavailable for download.', details: conversionError.message, videoId: videoId }); } } catch (error) { logger.error('Server error:', error); res.status(500).json({ error: 'Failed to process request' }); } }); // Direct Telegram API for sending audio app.post('/api/telegram-send', async (req: Request, res: Response) => { logger.telegram('Send request received'); try { const { userId, audioUrl, title, performer, thumbnail }: { userId?: string; audioUrl?: string; title?: string; performer?: string; thumbnail?: string } = req.body; logger.telegram('Sending to user', `${userId}: ${title}`); if (!userId || !audioUrl || !title) { return res.status(400).json({ error: 'Missing required fields' }); } const botInstance = (global as any).quixoticBot; if (!botInstance) { logger.error('Bot not available'); return res.status(500).json({ error: 'Bot not available' }); } const chatId = parseInt(userId); await botInstance.sendAudioFile(chatId, audioUrl, title, performer, thumbnail); logger.success('Audio sent successfully'); res.json({ success: true, message: 'Audio sent successfully' }); } catch (error: any) { logger.error('Send failed:', error.message); res.status(500).json({ error: error.message }); } }); // Serve download files app.use('/downloads', express.static(downloadsDir)); // Health check app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Version endpoint for client-side cache busting app.get('/api/version', (req: Request, res: Response) => { res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); try { const versionPath = path.join(__dirname, '../public/version.json'); if (fs.existsSync(versionPath)) { const versionData = fs.readFileSync(versionPath, 'utf8'); res.json(JSON.parse(versionData)); } else { res.json({ version: appVersion, timestamp: Date.now() }); } } catch (error) { res.json({ version: appVersion, timestamp: Date.now() }); } }); // Error handler app.use((err: Error, _req: Request, res: Response, _next: any) => { logger.error(err.stack || err.message); res.status(500).json({ error: 'Something went wrong!' }); }); // Cleanup old files periodically (every hour) setInterval(() => { const maxAge = 24 * 60 * 60 * 1000; // 24 hours const now = Date.now(); fs.readdir(downloadsDir, (err, files) => { if (err) return; files.forEach(file => { const filePath = path.join(downloadsDir, file); fs.stat(filePath, (err, stats) => { if (err) return; if (now - stats.mtime.getTime() > maxAge) { fs.unlink(filePath, (err) => { if (!err) { logger.info('Deleted old file:', file); } }); } }); }); }); }, 60 * 60 * 1000); // Run every hour app.listen(port, () => { logger.success(`Quixotic server running on port ${port}`); logger.info(`Downloads directory: ${downloadsDir}`); logger.info(`Open in browser: http://localhost:${port}`); }); // Initialize Telegram bot const botToken = process.env.TELEGRAM_BOT_TOKEN; const webAppUrl = process.env.WEB_APP_URL || `http://localhost:${port}`; if (botToken && botToken.length > 10 && botToken !== 'your_telegram_bot_token_here') { try { const botInstance = new QuixoticBot(botToken, webAppUrl); // Store bot instance globally for API access (global as any).quixoticBot = botInstance; logger.telegram('Bot started and stored globally'); } catch (error: any) { logger.error('Bot initialization failed:', error.message); logger.warn('Bot disabled due to error'); logger.warn('Telegram integration will not be available'); // Don't crash the server, continue without bot } } else { logger.warn('TELEGRAM_BOT_TOKEN not configured properly'); logger.warn('Bot will not start - only web interface will be available'); logger.info('To enable Telegram bot, set a valid TELEGRAM_BOT_TOKEN'); } // Handle unhandled promise rejections process.on('unhandledRejection', (reason: any, promise: Promise) => { logger.error('Unhandled Rejection at:', promise); logger.error('Reason:', reason); // Log but don't crash the server if (reason?.code === 'ETELEGRAM') { logger.warn('Telegram API error - continuing operation'); } }); // Handle uncaught exceptions process.on('uncaughtException', (error: Error) => { logger.error('Uncaught Exception:', error); // Log but try to continue }); export default app;