Initial commit: Complete Quixotic Telegram MiniApp implementation
- 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 <noreply@anthropic.com>
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal file
@@ -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
|
||||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -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/
|
||||||
175
README.md
Normal file
175
README.md
Normal file
@@ -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 <repository-url>
|
||||||
|
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 <repository-url>
|
||||||
|
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
|
||||||
51
WORKLOG.md
Normal file
51
WORKLOG.md
Normal file
@@ -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)
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/bot.js
Normal file
223
src/bot.js
Normal file
@@ -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;
|
||||||
104
src/database.js
Normal file
104
src/database.js
Normal file
@@ -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;
|
||||||
166
src/server.js
Normal file
166
src/server.js
Normal file
@@ -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;
|
||||||
173
src/youtube.js
Normal file
173
src/youtube.js
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user