From 2bfb456cf3d50121feea7c0332049e3c62004631 Mon Sep 17 00:00:00 2001 From: Andrey Kondratev Date: Mon, 25 Aug 2025 10:37:07 +0500 Subject: [PATCH] Initial commit: Complete Quixotic Telegram MiniApp implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set up Express.js server with YouTube search and MP3 conversion API - Created Telegram Web App frontend with responsive design - Implemented SQLite database for user management and history - Added Telegram Bot integration with commands and Web App support - Configured FFmpeg-based audio conversion pipeline - Added comprehensive documentation and deployment guides 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 10 +++ .gitignore | 42 +++++++++ README.md | 175 +++++++++++++++++++++++++++++++++++++ WORKLOG.md | 51 +++++++++++ package.json | 24 ++++++ src/bot.js | 223 ++++++++++++++++++++++++++++++++++++++++++++++++ src/database.js | 104 ++++++++++++++++++++++ src/server.js | 166 +++++++++++++++++++++++++++++++++++ src/youtube.js | 173 +++++++++++++++++++++++++++++++++++++ 9 files changed, 968 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 WORKLOG.md create mode 100644 package.json create mode 100644 src/bot.js create mode 100644 src/database.js create mode 100644 src/server.js create mode 100644 src/youtube.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..283c13f --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Telegram Bot Configuration +TELEGRAM_BOT_TOKEN=your_bot_token_here +WEB_APP_URL=https://your-domain.com + +# Server Configuration +PORT=3000 +NODE_ENV=development + +# Optional: Custom ffmpeg path +FFMPEG_PATH=/usr/local/bin/ffmpeg \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f0844a --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.production + +# Database +database/*.db +database/*.sqlite + +# Downloads +downloads/ +*.mp3 +*.mp4 + +# Logs +logs/ +*.log + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Runtime +.pid +.seed +.coverage + +# Build +dist/ +build/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8b12dc --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +# 🎵 Quixotic - YouTube to MP3 Telegram MiniApp + +Telegram miniapp для поиска музыки на YouTube и конвертации в MP3. + +## Возможности + +- 🔍 Поиск видео на YouTube +- 🎵 Конвертация в MP3 с помощью FFmpeg +- 📱 Telegram Web App интерфейс +- 💾 SQLite база данных +- 📊 История поиска +- 🤖 Telegram Bot интеграция + +## Установка + +### 1. Клонирование и установка зависимостей + +```bash +git clone +cd quixotic +npm install +``` + +### 2. Установка FFmpeg + +**macOS:** +```bash +brew install ffmpeg +``` + +**Ubuntu/Debian:** +```bash +sudo apt update +sudo apt install ffmpeg +``` + +**Windows:** +Скачайте с [ffmpeg.org](https://ffmpeg.org/download.html) + +### 3. Создание Telegram бота + +1. Напишите [@BotFather](https://t.me/BotFather) в Telegram +2. Создайте нового бота: `/newbot` +3. Получите токен бота +4. Настройте Web App: `/newapp` + +### 4. Настройка окружения + +```bash +cp .env.example .env +``` + +Отредактируйте `.env`: +```env +TELEGRAM_BOT_TOKEN=your_bot_token_here +WEB_APP_URL=https://your-domain.com +PORT=3000 +``` + +## Запуск + +### Разработка +```bash +npm run dev +``` + +### Продакшн +```bash +npm start +``` + +## Структура проекта + +``` +quixotic/ +├── src/ +│ ├── server.js # Express сервер +│ ├── bot.js # Telegram бот +│ ├── youtube.js # YouTube API +│ └── database.js # SQLite база данных +├── public/ +│ ├── index.html # Web App интерфейс +│ ├── style.css # Стили +│ └── script.js # JavaScript +├── database/ # SQLite файлы +├── downloads/ # MP3 файлы +└── package.json +``` + +## API Endpoints + +- `POST /api/search` - Поиск видео +- `POST /api/convert` - Конвертация в MP3 +- `GET /downloads/:filename` - Скачивание файлов +- `GET /health` - Проверка здоровья + +## База данных + +SQLite с таблицами: +- `users` - пользователи Telegram +- `search_history` - история поиска +- `downloads` - загруженные файлы + +## Деплой + +### Heroku + +1. Установите [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) +2. Создайте приложение: +```bash +heroku create quixotic-app +``` + +3. Установите buildpacks: +```bash +heroku buildpacks:add --index 1 https://github.com/jonathanong/heroku-buildpack-ffmpeg-latest.git +heroku buildpacks:add --index 2 heroku/nodejs +``` + +4. Настройте переменные: +```bash +heroku config:set TELEGRAM_BOT_TOKEN=your_token +heroku config:set WEB_APP_URL=https://quixotic-app.herokuapp.com +``` + +5. Деплой: +```bash +git push heroku main +``` + +### VPS (Ubuntu) + +```bash +# Обновление системы +sudo apt update && sudo apt upgrade -y + +# Установка Node.js +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - +sudo apt-get install -y nodejs + +# Установка FFmpeg +sudo apt install ffmpeg -y + +# Клонирование проекта +git clone +cd quixotic +npm install + +# Настройка PM2 +sudo npm install -g pm2 +pm2 start src/server.js --name quixotic +pm2 startup +pm2 save + +# Nginx (опционально) +sudo apt install nginx -y +# Настройте reverse proxy на порт 3000 +``` + +## Мониторинг + +```bash +# Логи сервера +pm2 logs quixotic + +# Статус +pm2 status + +# Перезапуск +pm2 restart quixotic +``` + +## Лицензия + +MIT License \ No newline at end of file diff --git a/WORKLOG.md b/WORKLOG.md new file mode 100644 index 0000000..a62fadc --- /dev/null +++ b/WORKLOG.md @@ -0,0 +1,51 @@ +# Quixotic - Work Log + +## 2025-08-25 + +### Project Setup +- [x] Initialized project structure with directories: src/, public/, database/ +- [x] Created package.json with required dependencies: + - express (web server) + - sqlite3 (database) + - node-telegram-bot-api (Telegram bot) + - ytdl-core (YouTube downloads) + - fluent-ffmpeg (MP3 conversion) + - axios (HTTP requests) +- [x] Set up SQLite database schema with tables: + - users (telegram users) + - search_history (user search queries) + - downloads (converted files tracking) +- [x] Initialized git repository + +### Completed Features +- [x] Create Telegram Web App HTML interface +- [x] Implement YouTube search functionality +- [x] Add video thumbnail display +- [x] Implement MP3 conversion with ffmpeg +- [x] Set up Telegram bot integration +- [x] Created configuration files (.env.example, .gitignore) +- [x] Added comprehensive README with deployment instructions + +### Technical Implementation +- **Frontend**: Responsive Web App with Telegram Web App SDK integration +- **Backend**: Express.js server with RESTful API +- **Database**: SQLite with user management and search history +- **YouTube**: Custom search implementation with fallback methods +- **Audio**: FFmpeg-based MP3 conversion pipeline +- **Bot**: Full Telegram Bot API integration with inline queries + +### Files Created +- `src/server.js` - Main Express server with API endpoints +- `src/bot.js` - Telegram bot with commands and Web App integration +- `src/youtube.js` - YouTube search and audio stream extraction +- `src/database.js` - SQLite database management +- `public/index.html` - Web App interface +- `public/style.css` - Responsive CSS with Telegram theming +- `public/script.js` - Frontend JavaScript with Web App SDK +- Configuration and documentation files + +### Next Steps for Deployment +- [ ] Install FFmpeg on target server +- [ ] Set up environment variables +- [ ] Configure Telegram bot with BotFather +- [ ] Deploy to hosting platform (Heroku/VPS) \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..734f205 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "quixotic", + "version": "1.0.0", + "description": "Telegram miniapp for YouTube music search and MP3 conversion", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js" + }, + "dependencies": { + "express": "^4.18.2", + "sqlite3": "^5.1.6", + "node-telegram-bot-api": "^0.64.0", + "ytdl-core": "^4.11.5", + "fluent-ffmpeg": "^2.1.2", + "axios": "^1.6.2" + }, + "devDependencies": { + "nodemon": "^3.0.2" + }, + "engines": { + "node": ">=16.0.0" + } +} \ No newline at end of file diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..39cdab5 --- /dev/null +++ b/src/bot.js @@ -0,0 +1,223 @@ +const TelegramBot = require('node-telegram-bot-api'); +const Database = require('./database'); + +class QuixoticBot { + constructor(token, webAppUrl) { + this.bot = new TelegramBot(token, { polling: true }); + this.webAppUrl = webAppUrl; + this.db = new Database(); + this.init(); + } + + init() { + console.log('> Telegram bot initialized'); + this.setupCommands(); + this.setupHandlers(); + } + + setupCommands() { + // Set bot commands + this.bot.setMyCommands([ + { command: 'start', description: '0?CAB8BL ?@8;>65=85' }, + { command: 'help', description: '><>IL' }, + { command: 'history', description: 'AB>@8O ?>8A:0' } + ]); + } + + setupHandlers() { + // Start command + this.bot.onText(/\/start/, async (msg) => { + const chatId = msg.chat.id; + const user = msg.from; + + try { + // Add user to database + await this.db.addUser(user); + + const keyboard = { + inline_keyboard: [[ + { + text: '< B:@KBL Quixotic', + web_app: { url: this.webAppUrl } + } + ]] + }; + + await this.bot.sendMessage(chatId, + '< >1@> ?>60;>20BL 2 Quixotic!\n\n' + + '0948 ;N1CN ?5A=N =0 YouTube 8 ?>;CG8 MP3 D09; ?@O<> 2 G0B.\n\n' + + '06<8 :=>?:C =865, GB>1K =0G0BL ?>8A::', + { reply_markup: keyboard } + ); + } catch (error) { + console.error('Start command error:', error); + await this.bot.sendMessage(chatId, '@>87>H;0 >H81:0. >?@>1C9B5 ?>765.'); + } + }); + + // Help command + this.bot.onText(/\/help/, async (msg) => { + const chatId = msg.chat.id; + + const helpText = `< *Quixotic - YouTube to MP3* + +*0: ?>;L7>20BLAO:* +1 06<8 :=>?:C "B:@KBL Quixotic" +2 2548 =0720=85 ?5A=8 2 ?>8A:>2CN AB@>:C +3 K15@8 =C6=K9 B@5: 87 A?8A:0 +4 >;CG8 MP3 D09; 2 G0B! + +*><0=4K:* +/start - 0?CAB8BL ?@8;>65=85 +/help - -B0 A?@02:0 +/history - AB>@8O ?>8A:0 + +*>7<>6=>AB8:* +" >8A: ?> YouTube +" KA>:>5 :0G5AB2> MP3 (128kbps) +" KAB@0O :>=25@B0F8O +" AB>@8O ?>8A:0`; + + await this.bot.sendMessage(chatId, helpText, { parse_mode: 'Markdown' }); + }); + + // History command + this.bot.onText(/\/history/, async (msg) => { + const chatId = msg.chat.id; + const userId = msg.from.id; + + try { + const user = await this.db.getUserByTelegramId(userId); + if (!user) { + await this.bot.sendMessage(chatId, 'AB>@8O ?>8A:0 ?CAB0.'); + return; + } + + // Get recent search history + const history = await this.db.db.all( + `SELECT query, created_at FROM search_history + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT 10`, + [user.id] + ); + + if (history.length === 0) { + await this.bot.sendMessage(chatId, 'AB>@8O ?>8A:0 ?CAB0.'); + return; + } + + let historyText = '= *>A;54=85 ?>8A:>2K5 70?@>AK:*\n\n'; + history.forEach((item, index) => { + const date = new Date(item.created_at).toLocaleDateString('ru-RU'); + historyText += `${index + 1}. ${item.query} _(${date})_\n`; + }); + + await this.bot.sendMessage(chatId, historyText, { parse_mode: 'Markdown' }); + } catch (error) { + console.error('History command error:', error); + await this.bot.sendMessage(chatId, 'H81:0 ?>;CG5=8O 8AB>@88.'); + } + }); + + // Handle web app data + this.bot.on('web_app_data', async (msg) => { + const chatId = msg.chat.id; + const data = JSON.parse(msg.web_app.data); + + try { + if (data.action === 'send_audio') { + await this.sendAudioFile(chatId, data.audioUrl, data.title); + } + } catch (error) { + console.error('Web app data error:', error); + await this.bot.sendMessage(chatId, 'H81:0 >1@01>B:8 40==KE.'); + } + }); + + // Handle inline queries for search + this.bot.on('inline_query', async (query) => { + const queryId = query.id; + const searchQuery = query.query; + + if (!searchQuery || searchQuery.length < 3) { + await this.bot.answerInlineQuery(queryId, []); + return; + } + + try { + const YouTubeService = require('./youtube'); + const youtube = new YouTubeService(); + const videos = await youtube.searchVideos(searchQuery, 5); + + const results = videos.map((video, index) => ({ + type: 'article', + id: `${index}`, + title: video.title, + description: `${video.channel} " ${this.formatDuration(video.duration)}`, + thumb_url: video.thumbnail, + input_message_content: { + message_text: `< ${video.title}\n= ${video.url}` + } + })); + + await this.bot.answerInlineQuery(queryId, results, { + cache_time: 300, + is_personal: true + }); + } catch (error) { + console.error('Inline query error:', error); + await this.bot.answerInlineQuery(queryId, []); + } + }); + + // Error handler + this.bot.on('error', (error) => { + console.error('Telegram bot error:', error); + }); + + console.log(' Bot handlers setup complete'); + } + + async sendAudioFile(chatId, audioUrl, title) { + try { + await this.bot.sendMessage(chatId, ' >43>B02;820N MP3 D09;...'); + + // Send audio file + await this.bot.sendAudio(chatId, audioUrl, { + title: title, + performer: 'Quixotic', + caption: `< ${title}` + }); + + } catch (error) { + console.error('Send audio error:', error); + await this.bot.sendMessage(chatId, + 'L 5 C40;>AL >B?@028BL 0C48>D09;. >?@>1C9B5 5I5 @07.\n\n' + + `@O<0O AAK;:0: ${audioUrl}` + ); + } + } + + formatDuration(seconds) { + if (!seconds) return ''; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + } +} + +// Initialize bot if this file is run directly +if (require.main === module) { + const token = process.env.TELEGRAM_BOT_TOKEN; + const webAppUrl = process.env.WEB_APP_URL || 'https://your-domain.com'; + + if (!token) { + console.error('L TELEGRAM_BOT_TOKEN environment variable is required'); + process.exit(1); + } + + new QuixoticBot(token, webAppUrl); +} + +module.exports = QuixoticBot; \ No newline at end of file diff --git a/src/database.js b/src/database.js new file mode 100644 index 0000000..1e29b93 --- /dev/null +++ b/src/database.js @@ -0,0 +1,104 @@ +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +class Database { + constructor() { + this.dbPath = path.join(__dirname, '../database/quixotic.db'); + this.db = new sqlite3.Database(this.dbPath); + this.init(); + } + + init() { + this.db.serialize(() => { + // Users table + this.db.run(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + telegram_id INTEGER UNIQUE NOT NULL, + username TEXT, + first_name TEXT, + last_name TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + + // Search history table + this.db.run(`CREATE TABLE IF NOT EXISTS search_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + query TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + )`); + + // Downloaded files table + this.db.run(`CREATE TABLE IF NOT EXISTS downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + youtube_id TEXT NOT NULL, + title TEXT NOT NULL, + file_path TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + )`); + }); + } + + addUser(telegramUser) { + return new Promise((resolve, reject) => { + const { id, username, first_name, last_name } = telegramUser; + this.db.run( + `INSERT OR REPLACE INTO users (telegram_id, username, first_name, last_name) + VALUES (?, ?, ?, ?)`, + [id, username, first_name, last_name], + function(err) { + if (err) reject(err); + else resolve(this.lastID); + } + ); + }); + } + + addSearchHistory(userId, query) { + return new Promise((resolve, reject) => { + this.db.run( + `INSERT INTO search_history (user_id, query) VALUES (?, ?)`, + [userId, query], + function(err) { + if (err) reject(err); + else resolve(this.lastID); + } + ); + }); + } + + addDownload(userId, youtubeId, title, filePath) { + return new Promise((resolve, reject) => { + this.db.run( + `INSERT INTO downloads (user_id, youtube_id, title, file_path) VALUES (?, ?, ?, ?)`, + [userId, youtubeId, title, filePath], + function(err) { + if (err) reject(err); + else resolve(this.lastID); + } + ); + }); + } + + getUserByTelegramId(telegramId) { + return new Promise((resolve, reject) => { + this.db.get( + `SELECT * FROM users WHERE telegram_id = ?`, + [telegramId], + (err, row) => { + if (err) reject(err); + else resolve(row); + } + ); + }); + } + + close() { + this.db.close(); + } +} + +module.exports = Database; \ No newline at end of file diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..f8815d6 --- /dev/null +++ b/src/server.js @@ -0,0 +1,166 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const ffmpeg = require('fluent-ffmpeg'); +const Database = require('./database'); +const YouTubeService = require('./youtube'); + +const app = express(); +const port = process.env.PORT || 3000; + +// Initialize services +const db = new Database(); +const youtube = new YouTubeService(); + +// Middleware +app.use(express.json()); +app.use(express.static('public')); + +// Ensure downloads directory exists +const downloadsDir = path.join(__dirname, '../downloads'); +if (!fs.existsSync(downloadsDir)) { + fs.mkdirSync(downloadsDir, { recursive: true }); +} + +// Routes +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, '../public/index.html')); +}); + +// Search videos +app.post('/api/search', async (req, res) => { + try { + const { query, userId } = req.body; + + if (!query || query.trim().length === 0) { + return res.status(400).json({ error: 'Query is required' }); + } + + // Save search history + if (userId && userId !== 'demo') { + try { + const user = await db.getUserByTelegramId(userId); + if (user) { + await db.addSearchHistory(user.id, query); + } + } catch (dbError) { + console.error('Database error:', dbError); + } + } + + const videos = await youtube.searchVideos(query.trim()); + res.json({ videos }); + + } catch (error) { + console.error('Search error:', error); + res.status(500).json({ error: 'Failed to search videos' }); + } +}); + +// Convert video to MP3 +app.post('/api/convert', async (req, res) => { + try { + const { videoId, title, userId } = req.body; + + 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)) { + const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`; + return res.json({ audioUrl, title }); + } + + // Get audio stream from YouTube + const audioStream = await youtube.getAudioStream(videoId); + + // Convert to MP3 using ffmpeg + await new Promise((resolve, reject) => { + ffmpeg(audioStream) + .audioCodec('libmp3lame') + .audioBitrate(128) + .format('mp3') + .output(outputPath) + .on('error', (err) => { + console.error('FFmpeg error:', err); + reject(err); + }) + .on('end', () => { + console.log('Conversion finished:', filename); + resolve(); + }) + .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) { + console.error('Database error:', dbError); + } + } + + const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`; + res.json({ audioUrl, title }); + + } catch (error) { + console.error('Conversion error:', error); + res.status(500).json({ error: 'Failed to convert video' }); + } +}); + +// Serve download files +app.use('/downloads', express.static(downloadsDir)); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Error handler +app.use((err, req, res, next) => { + console.error(err.stack); + 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) { + console.log('Deleted old file:', file); + } + }); + } + }); + }); + }); +}, 60 * 60 * 1000); // Run every hour + +app.listen(port, () => { + console.log(`< Quixotic server running on port ${port}`); + console.log(`= Downloads directory: ${downloadsDir}`); +}); + +module.exports = app; \ No newline at end of file diff --git a/src/youtube.js b/src/youtube.js new file mode 100644 index 0000000..0987931 --- /dev/null +++ b/src/youtube.js @@ -0,0 +1,173 @@ +const ytdl = require('ytdl-core'); +const axios = require('axios'); + +class YouTubeService { + constructor() { + // Using YouTube's internal API endpoint for search (no API key needed) + this.searchEndpoint = 'https://www.youtube.com/youtubei/v1/search?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'; + } + + async searchVideos(query, maxResults = 10) { + try { + const response = await axios.post(this.searchEndpoint, { + context: { + client: { + clientName: 'WEB', + clientVersion: '2.20230728.00.00' + } + }, + query: query + }); + + const contents = response.data?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents; + if (!contents) return []; + + const videoResults = []; + + for (const section of contents) { + const itemSection = section?.itemSectionRenderer?.contents; + if (!itemSection) continue; + + for (const item of itemSection) { + const videoRenderer = item?.videoRenderer; + if (!videoRenderer) continue; + + const video = this.parseVideoRenderer(videoRenderer); + if (video && videoResults.length < maxResults) { + videoResults.push(video); + } + } + } + + return videoResults; + } catch (error) { + console.error('YouTube search error:', error.message); + // Fallback to alternative method + return this.searchWithYtdl(query, maxResults); + } + } + + parseVideoRenderer(videoRenderer) { + try { + const videoId = videoRenderer.videoId; + if (!videoId) return null; + + const title = videoRenderer.title?.runs?.[0]?.text || 'Unknown Title'; + const channel = videoRenderer.ownerText?.runs?.[0]?.text || 'Unknown Channel'; + + // Get thumbnail + const thumbnails = videoRenderer.thumbnail?.thumbnails || []; + const thumbnail = thumbnails[thumbnails.length - 1]?.url || + `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; + + // Parse duration + let duration = 0; + const durationText = videoRenderer.lengthText?.simpleText; + if (durationText) { + duration = this.parseDuration(durationText); + } + + return { + id: videoId, + title, + channel, + thumbnail: thumbnail.startsWith('//') ? 'https:' + thumbnail : thumbnail, + duration, + url: `https://www.youtube.com/watch?v=${videoId}` + }; + } catch (error) { + console.error('Error parsing video:', error); + return null; + } + } + + async searchWithYtdl(query, maxResults = 10) { + // Fallback method using ytdl-core with search + try { + const searchUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`; + const response = await axios.get(searchUrl); + + const videoIds = this.extractVideoIds(response.data); + const results = []; + + for (let i = 0; i < Math.min(videoIds.length, maxResults); i++) { + try { + const info = await ytdl.getBasicInfo(videoIds[i]); + const videoDetails = info.videoDetails; + + results.push({ + id: videoDetails.videoId, + title: videoDetails.title, + channel: videoDetails.ownerChannelName || 'Unknown Channel', + thumbnail: videoDetails.thumbnails?.[0]?.url || + `https://img.youtube.com/vi/${videoDetails.videoId}/maxresdefault.jpg`, + duration: parseInt(videoDetails.lengthSeconds) || 0, + url: videoDetails.video_url + }); + } catch (error) { + console.error(`Error getting info for video ${videoIds[i]}:`, error.message); + } + } + + return results; + } catch (error) { + console.error('Fallback search error:', error.message); + return []; + } + } + + extractVideoIds(html) { + const videoIdRegex = /"videoId":"([^"]+)"/g; + const videoIds = []; + let match; + + while ((match = videoIdRegex.exec(html)) !== null) { + if (!videoIds.includes(match[1])) { + videoIds.push(match[1]); + } + } + + return videoIds; + } + + parseDuration(durationText) { + const parts = durationText.split(':').reverse(); + let seconds = 0; + + for (let i = 0; i < parts.length; i++) { + seconds += parseInt(parts[i]) * Math.pow(60, i); + } + + return seconds; + } + + async getVideoInfo(videoId) { + try { + const info = await ytdl.getBasicInfo(videoId); + return { + title: info.videoDetails.title, + author: info.videoDetails.author.name, + length: info.videoDetails.lengthSeconds, + formats: info.formats + }; + } catch (error) { + console.error('Error getting video info:', error); + throw error; + } + } + + async getAudioStream(videoId) { + try { + const stream = ytdl(videoId, { + quality: 'highestaudio', + filter: 'audioonly' + }); + return stream; + } catch (error) { + console.error('Error getting audio stream:', error); + throw error; + } + } +} + +module.exports = YouTubeService; \ No newline at end of file