This commit is contained in:
Andrey Kondratev
2025-09-09 15:39:28 +05:00
parent 3b2d5ece24
commit d4debf9b63
33 changed files with 1274 additions and 9585 deletions

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Backend package

Binary file not shown.

Binary file not shown.

336
backend/api.py Normal file
View File

@@ -0,0 +1,336 @@
import os
import asyncio
import subprocess
from pathlib import Path
from datetime import datetime
from typing import Optional, List
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel
import aiofiles
from .database import Database, User
from .vkmusic import VKMusicService, Track
from .bot import QuixoticBot
app = FastAPI(title="Quixotic API", version="1.0.0")
# Initialize services
db = Database()
vkmusic = VKMusicService()
bot_instance = None
# Ensure downloads directory exists
downloads_dir = Path(__file__).parent.parent / "downloads"
downloads_dir.mkdir(exist_ok=True)
# Request/Response models
class SearchRequest(BaseModel):
query: str
userId: Optional[str] = None
class SearchResponse(BaseModel):
videos: List[Track]
class ConvertRequest(BaseModel):
videoId: str
title: Optional[str] = None
userId: Optional[str] = None
url: Optional[str] = None
performer: Optional[str] = None
class ConvertResponse(BaseModel):
audioUrl: str
title: Optional[str] = None
class TelegramSendRequest(BaseModel):
userId: str
audioUrl: str
title: str
performer: Optional[str] = None
thumbnail: Optional[str] = None
class HealthResponse(BaseModel):
status: str
timestamp: str
# Routes
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Serve main HTML page with cache-busting"""
try:
public_dir = Path(__file__).parent.parent / "public"
index_path = public_dir / "index.html"
async with aiofiles.open(index_path, 'r', encoding='utf8') as f:
html = await f.read()
# Add timestamp for cache busting
timestamp = int(datetime.now().timestamp() * 1000)
html = html.replace('dist/script.js?v=2', f'dist/script.js?v={timestamp}')
return HTMLResponse(
content=html,
headers={
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to serve index: {str(e)}")
@app.post("/api/search", response_model=SearchResponse)
async def search_videos(request: SearchRequest):
"""Search for videos/tracks"""
try:
if not request.query or not request.query.strip():
raise HTTPException(status_code=400, detail="Query is required")
# Save search history
if request.userId and request.userId != 'demo':
try:
user = await db.get_user_by_telegram_id(request.userId)
if user:
await db.add_search_history(user.id, request.query)
except Exception as db_error:
print(f"Database error: {db_error}")
# Search for tracks
videos = await vkmusic.search_tracks(request.query.strip())
return SearchResponse(videos=videos)
except Exception as error:
print(f"Search error: {error}")
raise HTTPException(status_code=500, detail="Failed to search videos")
@app.post("/api/convert", response_model=ConvertResponse)
async def convert_video(request: ConvertRequest, http_request: Request):
"""Convert video to MP3"""
try:
if not request.videoId:
raise HTTPException(status_code=400, detail="Video ID is required")
# Generate safe filename
safe_title = "".join(c for c in (request.title or "") if c.isalnum() or c in (' ', '-', '_')).replace(' ', '_')[:50]
filename = f"{request.videoId}_{safe_title}.mp3"
output_path = downloads_dir / filename
# Check if file already exists
if output_path.exists():
print("File already exists, serving cached version")
base_url = f"{http_request.url.scheme}://{http_request.url.netloc}"
audio_url = f"{base_url}/downloads/{filename}"
return ConvertResponse(audioUrl=audio_url, title=request.title)
print(f"Starting MP3 conversion for: {request.title}")
# Download and convert using VK music service
if not request.url:
raise HTTPException(status_code=400, detail="Track URL is required")
success = await vkmusic.download_track(request.url, str(output_path))
if not success:
# Fallback: try using FFmpeg conversion
audio_data = await vkmusic.get_audio_stream(request.videoId, request.url)
if not audio_data:
raise HTTPException(
status_code=503,
detail="MP3 conversion failed. This track may be restricted or unavailable for download."
)
# Convert using FFmpeg
await convert_with_ffmpeg(audio_data, output_path)
# Save download record
if request.userId and request.userId != 'demo':
try:
user = await db.get_user_by_telegram_id(request.userId)
if user:
await db.add_download(user.id, request.videoId, request.title or "", str(output_path))
except Exception as db_error:
print(f"Database error: {db_error}")
base_url = f"{http_request.url.scheme}://{http_request.url.netloc}"
audio_url = f"{base_url}/downloads/{filename}"
print(f"Conversion successful, file available at: {audio_url}")
return ConvertResponse(audioUrl=audio_url, title=request.title)
except HTTPException:
raise
except Exception as error:
print(f"Server error: {error}")
raise HTTPException(status_code=500, detail="Failed to process request")
@app.post("/api/telegram-send")
async def telegram_send(request: TelegramSendRequest):
"""Send audio via Telegram bot"""
print("🚀 Telegram send request received")
try:
if not request.userId or not request.audioUrl or not request.title:
raise HTTPException(status_code=400, detail="Missing required fields")
if not bot_instance:
raise HTTPException(status_code=500, detail="Bot not available")
print(f"📤 Sending to user {request.userId}: {request.title}")
chat_id = int(request.userId)
await bot_instance.send_audio_file(
chat_id,
request.audioUrl,
request.title,
request.performer,
request.thumbnail
)
print("✅ Audio sent successfully")
return {"success": True, "message": "Audio sent successfully"}
except Exception as error:
print(f"❌ Send failed: {error}")
raise HTTPException(status_code=500, detail=str(error))
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""Health check endpoint"""
return HealthResponse(
status="ok",
timestamp=datetime.now().isoformat()
)
# Serve download files
app.mount("/downloads", StaticFiles(directory=str(downloads_dir)), name="downloads")
# Serve static files
public_dir = Path(__file__).parent.parent / "public"
@app.get("/style.css")
async def get_css():
"""Serve CSS file"""
css_path = public_dir / "style.css"
if css_path.exists():
return FileResponse(
path=css_path,
media_type="text/css",
headers={"Cache-Control": "no-cache, max-age=0"}
)
raise HTTPException(status_code=404, detail="CSS not found")
@app.get("/dist/script.js")
async def get_js():
"""Serve JavaScript file"""
js_path = public_dir / "dist" / "script.js"
if js_path.exists():
return FileResponse(
path=js_path,
media_type="application/javascript",
headers={"Cache-Control": "no-cache, max-age=0"}
)
raise HTTPException(status_code=404, detail="JavaScript not found")
# Mount remaining static files
if public_dir.exists():
app.mount("/static", StaticFiles(directory=str(public_dir)), name="static")
async def convert_with_ffmpeg(audio_data: bytes, output_path: Path):
"""Convert audio data to MP3 using FFmpeg"""
temp_input = output_path.with_suffix('.tmp')
try:
# Write audio data to temporary file
async with aiofiles.open(temp_input, 'wb') as f:
await f.write(audio_data)
# Convert using FFmpeg
cmd = [
'ffmpeg',
'-i', str(temp_input),
'-codec:a', 'libmp3lame',
'-b:a', '192k',
'-ac', '2',
'-ar', '44100',
'-f', 'mp3',
str(output_path),
'-y' # Overwrite output file
]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
print(f"FFmpeg error: {stderr.decode()}")
raise Exception("FFmpeg conversion failed")
print("MP3 conversion completed successfully")
finally:
# Clean up temporary file
if temp_input.exists():
temp_input.unlink()
# Cleanup task for old files
async def cleanup_old_files():
"""Clean up old download files"""
max_age = 24 * 60 * 60 # 24 hours in seconds
now = datetime.now().timestamp()
try:
for file_path in downloads_dir.iterdir():
if file_path.is_file():
file_age = now - file_path.stat().st_mtime
if file_age > max_age:
file_path.unlink()
print(f"Deleted old file: {file_path.name}")
except Exception as e:
print(f"Cleanup error: {e}")
# Startup event
@app.on_event("startup")
async def startup_event():
"""Initialize services on startup"""
global bot_instance
print("Starting Quixotic Python API...")
# Initialize Telegram bot
bot_token = os.getenv("TELEGRAM_BOT_TOKEN")
web_app_url = os.getenv("WEB_APP_URL", "http://localhost:8000")
if bot_token and len(bot_token) > 10:
try:
bot_instance = QuixoticBot(bot_token, web_app_url, db)
print("🤖 Telegram bot initialized")
except Exception as error:
print(f"❌ Bot initialization failed: {error}")
print("⚠️ Bot disabled due to error")
else:
print("⚠️ TELEGRAM_BOT_TOKEN not found or invalid - bot will not start")
# Start cleanup task
asyncio.create_task(periodic_cleanup())
async def periodic_cleanup():
"""Periodic cleanup task"""
while True:
await asyncio.sleep(3600) # Run every hour
await cleanup_old_files()
# Shutdown event
@app.on_event("shutdown")
async def shutdown_event():
"""Cleanup on shutdown"""
await vkmusic.close()
db.close()
if bot_instance:
await bot_instance.close()
print("Quixotic API shutdown complete")

163
backend/bot.py Normal file
View File

@@ -0,0 +1,163 @@
import os
from typing import Optional
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from telegram.ext import Application, CommandHandler, MessageHandler, filters
import httpx
from .database import Database, User
class QuixoticBot:
def __init__(self, token: str, web_app_url: str, database: Database):
self.token = token
self.web_app_url = web_app_url
self.db = database
self.bot = Bot(token=token)
self.application = Application.builder().token(token).build()
self.setup_handlers()
def setup_handlers(self):
"""Setup bot command and message handlers"""
# Command handlers
self.application.add_handler(CommandHandler("start", self.start_command))
self.application.add_handler(CommandHandler("help", self.help_command))
# Message handlers
self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_message))
async def start_command(self, update, context):
"""Handle /start command"""
user = update.effective_user
chat_id = update.effective_chat.id
# Save or update user in database
try:
db_user = await self.db.get_user_by_telegram_id(str(user.id))
if not db_user:
await self.db.add_user(
telegram_id=str(user.id),
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
language_code=user.language_code
)
except Exception as e:
print(f"Database error in start command: {e}")
# Create inline keyboard with web app button
keyboard = [
[InlineKeyboardButton(
"🎵 Open Quixotic",
web_app=WebAppInfo(url=self.web_app_url)
)]
]
reply_markup = InlineKeyboardMarkup(keyboard)
welcome_text = (
f"👋 Welcome to Quixotic, {user.first_name}!\n\n"
"🎵 Search and download music from SoundCloud\n"
"🚀 Fast MP3 conversion\n"
"📱 Easy-to-use interface\n\n"
"Click the button below to get started!"
)
await context.bot.send_message(
chat_id=chat_id,
text=welcome_text,
reply_markup=reply_markup
)
async def help_command(self, update, context):
"""Handle /help command"""
help_text = (
"🎵 *Quixotic Bot Help*\n\n"
"*Commands:*\n"
"/start - Start the bot and open the music app\n"
"/help - Show this help message\n\n"
"*How to use:*\n"
"1. Click 'Open Quixotic' to launch the web app\n"
"2. Search for your favorite songs\n"
"3. Convert and download as MP3\n"
"4. Music files will be sent directly to this chat\n\n"
"*Features:*\n"
"• SoundCloud music search\n"
"• High-quality MP3 conversion\n"
"• Fast downloads\n"
"• Search history tracking\n\n"
"Enjoy your music! 🎶"
)
await update.message.reply_text(
help_text,
parse_mode='Markdown'
)
async def handle_message(self, update, context):
"""Handle text messages"""
# For now, just respond with instructions to use the web app
keyboard = [
[InlineKeyboardButton(
"🎵 Open Quixotic",
web_app=WebAppInfo(url=self.web_app_url)
)]
]
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text(
"Use the web app to search and download music! 🎵",
reply_markup=reply_markup
)
async def send_audio_file(self, chat_id: int, audio_url: str, title: str,
performer: Optional[str] = None, thumbnail: Optional[str] = None):
"""Send audio file to user"""
try:
print(f"📤 Sending audio to chat {chat_id}")
# Download the audio file first
async with httpx.AsyncClient() as client:
response = await client.get(audio_url)
response.raise_for_status()
audio_data = response.content
# Send audio
await self.bot.send_audio(
chat_id=chat_id,
audio=audio_data,
title=title,
performer=performer or "Unknown Artist",
duration=None, # Let Telegram figure it out
caption=f"🎵 {title}" + (f" by {performer}" if performer else ""),
thumbnail=thumbnail if thumbnail else None
)
print(f"✅ Audio sent successfully to chat {chat_id}")
except Exception as e:
print(f"❌ Failed to send audio to chat {chat_id}: {e}")
# Send error message
error_message = (
"❌ Failed to send audio file. "
"The file might be too large or temporarily unavailable."
)
try:
await self.bot.send_message(chat_id=chat_id, text=error_message)
except Exception as msg_error:
print(f"❌ Failed to send error message: {msg_error}")
raise e
async def start_polling(self):
"""Start bot polling"""
print("🤖 Starting bot polling...")
await self.application.initialize()
await self.application.start()
await self.application.updater.start_polling()
async def close(self):
"""Close bot application"""
if self.application:
await self.application.stop()
await self.application.shutdown()
print("🤖 Bot closed")

135
backend/database.py Normal file
View File

@@ -0,0 +1,135 @@
import os
from datetime import datetime
from typing import Optional, List
from sqlalchemy import create_engine, Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session, relationship
from sqlalchemy.pool import StaticPool
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
telegram_id = Column(String, unique=True, nullable=False)
username = Column(String, nullable=True)
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
language_code = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
search_history = relationship("SearchHistory", back_populates="user")
downloads = relationship("Download", back_populates="user")
class SearchHistory(Base):
__tablename__ = "search_history"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
query = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationship
user = relationship("User", back_populates="search_history")
class Download(Base):
__tablename__ = "downloads"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
video_id = Column(String, nullable=False)
title = Column(String, nullable=False)
file_path = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationship
user = relationship("User", back_populates="downloads")
class Database:
def __init__(self):
# Get database URL from environment or use default
database_url = os.getenv("DATABASE_URL")
if not database_url:
raise ValueError("DATABASE_URL environment variable is required")
# Create engine
self.engine = create_engine(
database_url,
poolclass=StaticPool,
connect_args={"check_same_thread": False} if "sqlite" in database_url else {},
echo=False
)
# Create session factory
self.SessionLocal = sessionmaker(bind=self.engine)
# Create tables
self.init_db()
def init_db(self):
"""Initialize database tables"""
Base.metadata.create_all(bind=self.engine)
def get_session(self) -> Session:
"""Get database session"""
return self.SessionLocal()
def close(self):
"""Close database connection"""
self.engine.dispose()
async def get_user_by_telegram_id(self, telegram_id: str) -> Optional[User]:
"""Get user by Telegram ID"""
with self.get_session() as session:
return session.query(User).filter(User.telegram_id == telegram_id).first()
async def add_user(self, telegram_id: str, username: str = None,
first_name: str = None, last_name: str = None,
language_code: str = None) -> User:
"""Add new user"""
with self.get_session() as session:
user = User(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code
)
session.add(user)
session.commit()
session.refresh(user)
return user
async def add_search_history(self, user_id: int, query: str) -> SearchHistory:
"""Add search history record"""
with self.get_session() as session:
history = SearchHistory(user_id=user_id, query=query)
session.add(history)
session.commit()
session.refresh(history)
return history
async def add_download(self, user_id: int, video_id: str, title: str, file_path: str) -> Download:
"""Add download record"""
with self.get_session() as session:
download = Download(
user_id=user_id,
video_id=video_id,
title=title,
file_path=file_path
)
session.add(download)
session.commit()
session.refresh(download)
return download
async def get_search_history(self, user_id: int, limit: int = 10) -> List[SearchHistory]:
"""Get user search history"""
with self.get_session() as session:
return session.query(SearchHistory)\
.filter(SearchHistory.user_id == user_id)\
.order_by(SearchHistory.created_at.desc())\
.limit(limit)\
.all()

110
backend/vkmusic.py Normal file
View File

@@ -0,0 +1,110 @@
import os
import asyncio
import subprocess
from typing import List, Optional, Dict, Any
from dataclasses import dataclass
import httpx
import json
import vk_api
from vk_api.audio import VkAudio
@dataclass
class Track:
id: str
title: str
artist: str
duration: int
thumbnail: str
url: str
permalink: str
class VKMusicService:
def __init__(self):
self.vk_login = os.getenv("VK_LOGIN")
self.vk_password = os.getenv("VK_PASSWORD")
self.vk_session = None
self.vk_audio = None
self.client = httpx.AsyncClient()
if self.vk_login and self.vk_password:
try:
self.vk_session = vk_api.VkApi(self.vk_login, self.vk_password)
self.vk_session.auth()
self.vk_audio = VkAudio(self.vk_session)
print("✅ VK Music service initialized")
except Exception as e:
print(f"❌ VK Music initialization failed: {e}")
self.vk_session = None
self.vk_audio = None
else:
print("⚠️ VK_LOGIN or VK_PASSWORD not configured")
async def search_tracks(self, query: str, limit: int = 10) -> List[Track]:
"""Search for tracks on VK Music"""
try:
if not self.vk_audio:
print("VK Audio not available, returning empty results")
return []
# Search for audio tracks
search_results = self.vk_audio.search(q=query, count=limit)
tracks = []
for item in search_results:
# VK Audio returns dict with keys: artist, title, duration, url, etc.
track = Track(
id=str(item.get('id', '')),
title=item.get('title', 'Unknown Title'),
artist=item.get('artist', 'Unknown Artist'),
duration=item.get('duration', 0),
thumbnail=item.get('album', {}).get('thumb', {}).get('photo_600', '') if item.get('album') else '',
url=item.get('url', ''),
permalink=item.get('url', '')
)
tracks.append(track)
return tracks
except Exception as e:
print(f"VK search error: {e}")
return []
async def get_audio_stream(self, track_id: str, track_url: str) -> Optional[bytes]:
"""Get audio stream for a track"""
try:
if not track_url:
return None
# Download audio stream directly from VK URL
response = await self.client.get(track_url)
response.raise_for_status()
return response.content
except Exception as e:
print(f"Audio stream error: {e}")
return None
async def download_track(self, track_url: str, output_path: str) -> bool:
"""Download track directly to file"""
try:
if not track_url:
return False
# Download directly from VK URL
response = await self.client.get(track_url)
response.raise_for_status()
# Save to file
with open(output_path, 'wb') as f:
f.write(response.content)
return True
except Exception as e:
print(f"Download track error: {e}")
return False
async def close(self):
"""Close HTTP client"""
await self.client.aclose()