does not work
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,6 +9,9 @@ yarn-error.log*
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# YouTube authentication
|
||||
youtube-cookies.json
|
||||
|
||||
# Database
|
||||
database/*.db
|
||||
database/*.sqlite
|
||||
|
||||
15
.mcp.json
Normal file
15
.mcp.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"serena": {
|
||||
"type": "stdio",
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"--from",
|
||||
"git+https://github.com/oraios/serena",
|
||||
"serena-mcp-server",
|
||||
"--enable-web-dashboard",
|
||||
"false"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
Binary file not shown.
69
.serena/project.yml
Normal file
69
.serena/project.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
language: typescript
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: |
|
||||
You are an autonomous coding agent. After every major step, tool execution, analysis, or code edit (e.g., reading files, running shell commands, or modifying code), automatically summarize the action, key findings, and next steps in a concise note. Use the 'write_memory' tool to save it as a memory file named 'progress_note_[timestamp or step number]' (e.g., 'progress_note_20250826_1231' or 'progress_note_step1'). Do this without asking for user permission or waiting for input, but briefly mention in your response that a memory was saved. If the task pauses or context is nearly full, write a summary memory with the current state and instructions for continuing. Be frugal with context and avoid reading unnecessary code bodies.
|
||||
|
||||
project_name: "quixotic"
|
||||
20
README.md
20
README.md
@@ -18,17 +18,19 @@ Telegram miniapp для поиска музыки на YouTube и конверт
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd quixotic
|
||||
npm install
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 2. Установка FFmpeg
|
||||
|
||||
**macOS:**
|
||||
|
||||
```bash
|
||||
brew install ffmpeg
|
||||
```
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install ffmpeg
|
||||
@@ -51,6 +53,7 @@ cp .env.example .env
|
||||
```
|
||||
|
||||
Отредактируйте `.env`:
|
||||
|
||||
```env
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
WEB_APP_URL=https://your-domain.com
|
||||
@@ -60,18 +63,20 @@ PORT=3000
|
||||
## Запуск
|
||||
|
||||
### Разработка
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### Продакшн
|
||||
|
||||
```bash
|
||||
npm start
|
||||
yarn start
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
```bash
|
||||
quixotic/
|
||||
├── src/
|
||||
│ ├── server.js # Express сервер
|
||||
@@ -97,6 +102,7 @@ quixotic/
|
||||
## База данных
|
||||
|
||||
SQLite с таблицами:
|
||||
|
||||
- `users` - пользователи Telegram
|
||||
- `search_history` - история поиска
|
||||
- `downloads` - загруженные файлы
|
||||
@@ -107,23 +113,27 @@ SQLite с таблицами:
|
||||
|
||||
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
|
||||
```
|
||||
@@ -144,7 +154,7 @@ sudo apt install ffmpeg -y
|
||||
# Клонирование проекта
|
||||
git clone <repository-url>
|
||||
cd quixotic
|
||||
npm install
|
||||
yarn install
|
||||
|
||||
# Настройка PM2
|
||||
sudo npm install -g pm2
|
||||
|
||||
70
YOUTUBE_SETUP.md
Normal file
70
YOUTUBE_SETUP.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# YouTube Authentication Setup
|
||||
|
||||
Чтобы обойти блокировку YouTube "Sign in to confirm you're not a bot", нужно использовать cookies из вашего авторизованного браузера.
|
||||
|
||||
## Шаг 1: Получение cookies
|
||||
|
||||
1. Откройте Chrome/Firefox и зайдите на [youtube.com](https://youtube.com)
|
||||
2. Убедитесь что вы авторизованы в своем аккаунте
|
||||
3. Нажмите F12 чтобы открыть Developer Tools
|
||||
4. Перейдите на вкладку **Application** (Chrome) или **Storage** (Firefox)
|
||||
5. В левом меню найдите **Cookies** → **https://www.youtube.com**
|
||||
6. Найдите и скопируйте значения следующих cookies:
|
||||
|
||||
### Обязательные cookies:
|
||||
- `__Secure-1PSID`
|
||||
- `__Secure-3PSID`
|
||||
- `__Secure-1PAPISID`
|
||||
- `__Secure-3PAPISID`
|
||||
|
||||
### Дополнительные (рекомендуемые):
|
||||
- `VISITOR_INFO1_LIVE`
|
||||
- `YSC`
|
||||
|
||||
## Шаг 2: Настройка файла
|
||||
|
||||
1. Откройте файл `youtube-cookies.json` в корне проекта
|
||||
2. Замените `your_*_value_here` на реальные значения из браузера:
|
||||
|
||||
```json
|
||||
{
|
||||
"comment": "Replace these values with your actual YouTube cookies from browser",
|
||||
"__Secure-1PSID": "СКОПИРОВАННОЕ_ЗНАЧЕНИЕ_ЗДЕСЬ",
|
||||
"__Secure-3PSID": "СКОПИРОВАННОЕ_ЗНАЧЕНИЕ_ЗДЕСЬ",
|
||||
"__Secure-1PAPISID": "СКОПИРОВАННОЕ_ЗНАЧЕНИЕ_ЗДЕСЬ",
|
||||
"__Secure-3PAPISID": "СКОПИРОВАННОЕ_ЗНАЧЕНИЕ_ЗДЕСЬ",
|
||||
"VISITOR_INFO1_LIVE": "СКОПИРОВАННОЕ_ЗНАЧЕНИЕ_ЗДЕСЬ",
|
||||
"YSC": "СКОПИРОВАННОЕ_ЗНАЧЕНИЕ_ЗДЕСЬ"
|
||||
}
|
||||
```
|
||||
|
||||
## Шаг 3: Перезапуск сервера
|
||||
|
||||
После настройки cookies перезапустите сервер:
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
В логах вы должны увидеть:
|
||||
```
|
||||
YouTube cookies loaded successfully
|
||||
```
|
||||
|
||||
## Примечания
|
||||
|
||||
- Cookies периодически истекают, и их нужно обновлять
|
||||
- Файл `youtube-cookies.json` добавлен в `.gitignore` для безопасности
|
||||
- Если cookies не работают, попробуйте обновить их из браузера
|
||||
- В случае проблем сервер автоматически переключится на анонимный доступ
|
||||
|
||||
## Альтернативный способ
|
||||
|
||||
Если cookies не помогают, можно установить системный `yt-dlp`:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install yt-dlp
|
||||
|
||||
# Затем использовать через exec в Node.js
|
||||
```
|
||||
39
eslint.config.js
Normal file
39
eslint.config.js
Normal file
@@ -0,0 +1,39 @@
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.js"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "commonjs",
|
||||
globals: {
|
||||
// Node.js globals
|
||||
console: "readonly",
|
||||
process: "readonly",
|
||||
Buffer: "readonly",
|
||||
__dirname: "readonly",
|
||||
__filename: "readonly",
|
||||
module: "readonly",
|
||||
require: "readonly",
|
||||
exports: "readonly",
|
||||
global: "readonly",
|
||||
setTimeout: "readonly",
|
||||
clearTimeout: "readonly",
|
||||
setInterval: "readonly",
|
||||
clearInterval: "readonly",
|
||||
URLSearchParams: "readonly",
|
||||
// Browser globals (for public/ files)
|
||||
window: "readonly",
|
||||
document: "readonly",
|
||||
fetch: "readonly",
|
||||
event: "readonly"
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"indent": ["error", 4],
|
||||
"quotes": ["error", "single"],
|
||||
"semi": ["error", "always"],
|
||||
"no-unused-vars": "warn",
|
||||
"no-console": "off",
|
||||
"no-undef": "error"
|
||||
}
|
||||
}
|
||||
];
|
||||
5710
package-lock.json
generated
Normal file
5710
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -5,17 +5,24 @@
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js"
|
||||
"dev": "nodemon src/server.js",
|
||||
"lint": "eslint src/ public/",
|
||||
"lint:fix": "eslint src/ public/ --fix",
|
||||
"validate": "npm run lint && node -c src/server.js && echo '✅ All checks passed!'",
|
||||
"pretest": "npm run validate"
|
||||
},
|
||||
"packageManager": "yarn@1.22.19",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"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"
|
||||
"node-fetch": "^3.3.2",
|
||||
"node-telegram-bot-api": "^0.64.0",
|
||||
"soundcloud-downloader": "^1.0.0",
|
||||
"sqlite3": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^9.34.0",
|
||||
"nodemon": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -10,20 +10,20 @@
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1><µ Quixotic</h1>
|
||||
<p>0948 8 A:0G09 <C7K:C 87 YouTube</p>
|
||||
<h1>🎵 Quixotic</h1>
|
||||
<p>Найди и скачай музыку из YouTube</p>
|
||||
</header>
|
||||
|
||||
<div class="search-section">
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput" placeholder="2548B5 =0720=85 ?5A=8..." autocomplete="off">
|
||||
<button id="searchBtn">=
|
||||
<input type="text" id="searchInput" placeholder="Введите название песни..." autocomplete="off">
|
||||
<button id="searchBtn">🔍</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading hidden">
|
||||
<div class="spinner"></div>
|
||||
<div class="spinner"></div>
|
||||
<p>Поиск...</p>
|
||||
</div>
|
||||
|
||||
<div id="results" class="results-container">
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<div id="noResults" class="no-results hidden">
|
||||
<div id="noResults" class="no-results hidden">
|
||||
<p>Ничего не найдено. Попробуйте другой запрос.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -100,10 +100,12 @@ class QuixoticApp {
|
||||
}
|
||||
|
||||
async convertVideo(videoId, title) {
|
||||
console.log('Convert video called:', { videoId, title });
|
||||
const videoElement = event.currentTarget;
|
||||
videoElement.classList.add('converting');
|
||||
|
||||
try {
|
||||
console.log('Sending convert request...');
|
||||
const response = await fetch('/api/convert', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -116,29 +118,55 @@ class QuixoticApp {
|
||||
})
|
||||
});
|
||||
|
||||
console.log('Response status:', response.status);
|
||||
if (!response.ok) {
|
||||
throw new Error('Conversion failed');
|
||||
throw new Error(`Conversion failed with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Response data:', data);
|
||||
|
||||
if (this.tg) {
|
||||
this.tg.sendData(JSON.stringify({
|
||||
action: 'send_audio',
|
||||
audioUrl: data.audioUrl,
|
||||
title: title
|
||||
}));
|
||||
if (data.audioUrl) {
|
||||
// MP3 conversion successful!
|
||||
console.log('MP3 conversion successful:', data.audioUrl);
|
||||
|
||||
if (this.tg) {
|
||||
// Send to Telegram chat
|
||||
this.tg.sendData(JSON.stringify({
|
||||
action: 'send_audio',
|
||||
audioUrl: data.audioUrl,
|
||||
title: title
|
||||
}));
|
||||
this.showMessage('✓ MP3 готов! Отправляем в чат...', 'success');
|
||||
} else {
|
||||
// For testing in browser - download file
|
||||
const link = document.createElement('a');
|
||||
link.href = data.audioUrl;
|
||||
link.download = `${title}.mp3`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
this.showMessage('✓ MP3 скачан!', 'success');
|
||||
}
|
||||
} else {
|
||||
// Fallback for testing without Telegram
|
||||
window.open(data.audioUrl, '_blank');
|
||||
// Should not happen since we removed fallbacks
|
||||
throw new Error('No audio URL received');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Conversion error:', error);
|
||||
if (this.tg) {
|
||||
this.tg.showAlert('H81:0 ?@8 :>=25@B0F88. >?@>1C9B5 5I5 @07.');
|
||||
} else {
|
||||
alert('H81:0 ?@8 :>=25@B0F88. >?@>1C9B5 5I5 @07.');
|
||||
|
||||
// Show specific error message
|
||||
let errorMsg = 'Ошибка конвертации. Попробуйте другое видео.';
|
||||
|
||||
if (error.message.includes('No audio URL')) {
|
||||
errorMsg = 'Не удалось получить аудио файл.';
|
||||
} else if (error.message.includes('410')) {
|
||||
errorMsg = 'Видео недоступно для скачивания.';
|
||||
} else if (error.message.includes('403')) {
|
||||
errorMsg = 'Видео заблокировано для скачивания.';
|
||||
}
|
||||
|
||||
this.showMessage(`❌ ${errorMsg}`, 'warning');
|
||||
} finally {
|
||||
videoElement.classList.remove('converting');
|
||||
}
|
||||
@@ -151,6 +179,30 @@ class QuixoticApp {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
showMessage(message, type = 'info') {
|
||||
// Remove existing message if any
|
||||
const existingMessage = document.querySelector('.status-message');
|
||||
if (existingMessage) {
|
||||
existingMessage.remove();
|
||||
}
|
||||
|
||||
// Create message element
|
||||
const messageEl = document.createElement('div');
|
||||
messageEl.className = `status-message status-${type}`;
|
||||
messageEl.textContent = message;
|
||||
|
||||
// Add to page
|
||||
const container = document.querySelector('.container');
|
||||
container.insertBefore(messageEl, container.firstChild);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (messageEl.parentNode) {
|
||||
messageEl.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
|
||||
@@ -113,6 +113,7 @@ header p {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-item:hover {
|
||||
@@ -178,7 +179,7 @@ header p {
|
||||
}
|
||||
|
||||
.converting::after {
|
||||
content: ">=25@B0F8O...";
|
||||
content: "Converting to MP3...";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@@ -190,6 +191,44 @@ header p {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background-color: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.video-item {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -60,7 +60,7 @@ class Database {
|
||||
addSearchHistory(userId, query) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
`INSERT INTO search_history (user_id, query) VALUES (?, ?)`,
|
||||
'INSERT INTO search_history (user_id, query) VALUES (?, ?)',
|
||||
[userId, query],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
@@ -73,7 +73,7 @@ class Database {
|
||||
addDownload(userId, youtubeId, title, filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
`INSERT INTO downloads (user_id, youtube_id, title, file_path) VALUES (?, ?, ?, ?)`,
|
||||
'INSERT INTO downloads (user_id, youtube_id, title, file_path) VALUES (?, ?, ?, ?)',
|
||||
[userId, youtubeId, title, filePath],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
@@ -86,7 +86,7 @@ class Database {
|
||||
getUserByTelegramId(telegramId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(
|
||||
`SELECT * FROM users WHERE telegram_id = ?`,
|
||||
'SELECT * FROM users WHERE telegram_id = ?',
|
||||
[telegramId],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
|
||||
107
src/server.js
107
src/server.js
@@ -3,14 +3,14 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
const Database = require('./database');
|
||||
const YouTubeService = require('./youtube');
|
||||
const SoundCloudService = require('./soundcloud');
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// Initialize services
|
||||
const db = new Database();
|
||||
const youtube = new YouTubeService();
|
||||
const soundcloud = new SoundCloudService();
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
@@ -48,7 +48,7 @@ app.post('/api/search', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const videos = await youtube.searchVideos(query.trim());
|
||||
const videos = await soundcloud.searchTracks(query.trim());
|
||||
res.json({ videos });
|
||||
|
||||
} catch (error) {
|
||||
@@ -61,6 +61,7 @@ app.post('/api/search', async (req, res) => {
|
||||
app.post('/api/convert', async (req, res) => {
|
||||
try {
|
||||
const { videoId, title, userId } = req.body;
|
||||
console.log('Convert request received:', { videoId, title, userId });
|
||||
|
||||
if (!videoId) {
|
||||
return res.status(400).json({ error: 'Video ID is required' });
|
||||
@@ -73,49 +74,80 @@ app.post('/api/convert', async (req, res) => {
|
||||
|
||||
// Check if file already exists
|
||||
if (fs.existsSync(outputPath)) {
|
||||
console.log('File already exists, serving cached version');
|
||||
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);
|
||||
console.log(`Starting MP3 conversion for: ${title}`);
|
||||
|
||||
// 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();
|
||||
});
|
||||
try {
|
||||
// Get audio stream from YouTube
|
||||
console.log(`Attempting to get audio stream for: ${videoId}`);
|
||||
const audioStream = await soundcloud.getAudioStream(videoId);
|
||||
console.log('Audio stream obtained, starting FFmpeg conversion...');
|
||||
|
||||
// Save download record
|
||||
if (userId && userId !== 'demo') {
|
||||
try {
|
||||
const user = await db.getUserByTelegramId(userId);
|
||||
if (user) {
|
||||
await db.addDownload(user.id, videoId, title, outputPath);
|
||||
// Convert to MP3 using ffmpeg
|
||||
await new Promise((resolve, reject) => {
|
||||
const conversion = ffmpeg(audioStream)
|
||||
.audioCodec('libmp3lame')
|
||||
.audioBitrate('192k')
|
||||
.audioChannels(2)
|
||||
.audioFrequency(44100)
|
||||
.format('mp3')
|
||||
.output(outputPath)
|
||||
.on('start', (command) => {
|
||||
console.log('FFmpeg started:', command);
|
||||
})
|
||||
.on('progress', (progress) => {
|
||||
if (progress.percent) {
|
||||
console.log(`Conversion progress: ${Math.round(progress.percent)}%`);
|
||||
}
|
||||
})
|
||||
.on('end', () => {
|
||||
console.log('MP3 conversion completed successfully');
|
||||
resolve();
|
||||
})
|
||||
.on('error', (err) => {
|
||||
console.error('FFmpeg error:', err.message);
|
||||
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) {
|
||||
console.error('Database error:', dbError);
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.error('Database error:', dbError);
|
||||
}
|
||||
|
||||
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
|
||||
console.log('Conversion successful, file available at:', audioUrl);
|
||||
res.json({ audioUrl, title });
|
||||
|
||||
} catch (conversionError) {
|
||||
console.error('Conversion failed for video:', videoId);
|
||||
console.error('Error details:', conversionError.message);
|
||||
console.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
|
||||
});
|
||||
}
|
||||
|
||||
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' });
|
||||
console.error('Server error:', error);
|
||||
res.status(500).json({ error: 'Failed to process request' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -159,8 +191,9 @@ setInterval(() => {
|
||||
}, 60 * 60 * 1000); // Run every hour
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`<µ Quixotic server running on port ${port}`);
|
||||
console.log(`=Á Downloads directory: ${downloadsDir}`);
|
||||
console.log(`Quixotic server running on port ${port}`);
|
||||
console.log(`Downloads directory: ${downloadsDir}`);
|
||||
console.log(`Open in browser: http://localhost:${port}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
101
src/soundcloud.js
Normal file
101
src/soundcloud.js
Normal file
@@ -0,0 +1,101 @@
|
||||
const scdl = require('soundcloud-downloader').default;
|
||||
|
||||
|
||||
class SoundCloudService {
|
||||
constructor() {
|
||||
console.log('SoundCloud service initialized');
|
||||
}
|
||||
|
||||
async searchTracks(query, maxResults = 10) {
|
||||
try {
|
||||
console.log(`Searching SoundCloud for: ${query}`);
|
||||
|
||||
// Search for tracks on SoundCloud
|
||||
const tracks = await scdl.search({
|
||||
query: query,
|
||||
limit: maxResults,
|
||||
resourceType: 'tracks'
|
||||
});
|
||||
|
||||
if (!tracks || tracks.length === 0) {
|
||||
console.log('No tracks found');
|
||||
return [];
|
||||
}
|
||||
|
||||
const trackResults = tracks.map(track => ({
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
channel: track.user?.username || 'Unknown Artist',
|
||||
thumbnail: track.artwork_url || track.user?.avatar_url || 'https://via.placeholder.com/300x300?text=No+Image',
|
||||
duration: Math.floor(track.duration / 1000) || 0, // Convert from ms to seconds
|
||||
url: track.permalink_url,
|
||||
streamable: track.streamable,
|
||||
downloadable: track.downloadable
|
||||
}));
|
||||
|
||||
console.log(`Found ${trackResults.length} tracks on SoundCloud`);
|
||||
return trackResults;
|
||||
|
||||
} catch (error) {
|
||||
console.error('SoundCloud search error:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getTrackInfo(trackId) {
|
||||
try {
|
||||
const track = await scdl.getInfo(trackId);
|
||||
return {
|
||||
title: track.title,
|
||||
author: track.user?.username || 'Unknown',
|
||||
length: Math.floor(track.duration / 1000),
|
||||
available: track.streamable
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting track info:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAudioStream(trackId) {
|
||||
try {
|
||||
console.log(`Getting audio stream for track: ${trackId}`);
|
||||
|
||||
// Get track info first
|
||||
const trackInfo = await scdl.getInfo(trackId);
|
||||
|
||||
if (!trackInfo.streamable) {
|
||||
throw new Error('Track is not streamable');
|
||||
}
|
||||
|
||||
console.log(`Track: ${trackInfo.title}`);
|
||||
console.log(`Artist: ${trackInfo.user?.username || 'Unknown'}`);
|
||||
console.log(`Duration: ${Math.floor(trackInfo.duration / 1000)}s`);
|
||||
|
||||
// Get audio stream
|
||||
const stream = await scdl.download(trackId);
|
||||
|
||||
console.log('Audio stream obtained successfully from SoundCloud');
|
||||
return stream;
|
||||
|
||||
} catch (error) {
|
||||
console.error('SoundCloud download failed:', error.message);
|
||||
|
||||
// Try alternative approach
|
||||
try {
|
||||
console.log('Trying alternative SoundCloud method...');
|
||||
const trackUrl = `https://soundcloud.com/track/${trackId}`;
|
||||
const stream = await scdl.download(trackUrl);
|
||||
|
||||
console.log('Audio stream obtained with alternative method');
|
||||
return stream;
|
||||
|
||||
} catch (fallbackError) {
|
||||
console.error('Alternative method also failed:', fallbackError.message);
|
||||
throw new Error(`SoundCloud download failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SoundCloudService;
|
||||
173
src/youtube.js
173
src/youtube.js
@@ -1,173 +0,0 @@
|
||||
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