336 lines
11 KiB
Python
336 lines
11 KiB
Python
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") |