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