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

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")