python
This commit is contained in:
336
backend/api.py
Normal file
336
backend/api.py
Normal 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")
|
||||
Reference in New Issue
Block a user