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