import asyncio from contextlib import asynccontextmanager from pathlib import Path from concurrent.futures import ThreadPoolExecutor from typing import List from fastapi import FastAPI, HTTPException, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from .config import settings from .storage import storage from .models import ( GenerateRequest, GenerateResponse, ContentListResponse, ) from .video_generator import VideoGenerator, check_ffmpeg from .database import init_db, async_session_maker from .db_models import Opening from .routers import openings, backgrounds from .openings_downloader.router import router as downloader_router from .openings_downloader import db_models as downloader_db_models # noqa: F401 - import for table creation from sqlalchemy import select, update from datetime import datetime, timezone @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan handler.""" # Startup print("Initializing database...") await init_db() print("Database initialized") yield # Shutdown print("Shutting down...") app = FastAPI( title="Anime Quiz Video Generator", description="Generate 'Guess the Anime Opening' videos for YouTube and TikTok", version="1.0.0", lifespan=lifespan, ) # CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Include routers app.include_router(openings.router, prefix="/api") app.include_router(backgrounds.router, prefix="/api") app.include_router(downloader_router, prefix="/api") # Mount output directory for serving videos app.mount("/videos", StaticFiles(directory=str(settings.output_path)), name="videos") # Thread pool for video generation executor = ThreadPoolExecutor(max_workers=1) # Store generation status generation_status: dict[str, dict] = {} @app.get("/health") async def health_check(): """Health check endpoint.""" ffmpeg_ok = check_ffmpeg() return { "status": "healthy" if ffmpeg_ok else "degraded", "ffmpeg": ffmpeg_ok, "s3_endpoint": settings.s3_endpoint, "s3_bucket": settings.s3_bucket, "output_path": str(settings.output_path), } @app.get("/content", response_model=ContentListResponse) async def list_content(): """List available media content from S3.""" return ContentListResponse( audio_files=storage.list_audio_files(), background_videos=storage.list_background_videos(), posters=storage.list_posters(), transition_sounds=storage.list_transition_sounds(), ) @app.get("/media/posters/{filename}") async def get_poster(filename: str): """Get poster image from S3.""" poster_path = storage.get_poster_file(filename) if not poster_path or not poster_path.exists(): raise HTTPException(status_code=404, detail="Poster not found") # Determine media type suffix = poster_path.suffix.lower() media_types = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", } media_type = media_types.get(suffix, "application/octet-stream") return FileResponse(path=str(poster_path), media_type=media_type) @app.get("/media/audio/{filename}") async def get_audio(filename: str): """Get audio file from S3.""" audio_path = storage.get_audio_file(filename) if not audio_path or not audio_path.exists(): raise HTTPException(status_code=404, detail="Audio not found") suffix = audio_path.suffix.lower() media_types = { ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".m4a": "audio/mp4", } media_type = media_types.get(suffix, "audio/mpeg") return FileResponse(path=str(audio_path), media_type=media_type) @app.get("/media/transitions/{filename}") async def get_transition(filename: str): """Get transition sound from S3.""" transition_path = storage.get_transition_file(filename) if not transition_path or not transition_path.exists(): raise HTTPException(status_code=404, detail="Transition sound not found") suffix = transition_path.suffix.lower() media_types = { ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", } media_type = media_types.get(suffix, "audio/mpeg") return FileResponse(path=str(transition_path), media_type=media_type) def run_generation(request: GenerateRequest, task_id: str) -> Path: """Run video generation in thread pool.""" generation_status[task_id] = {"status": "processing", "progress": 0, "message": "Starting generation..."} try: generator = VideoGenerator(request) generation_status[task_id]["message"] = "Generating video..." generation_status[task_id]["progress"] = 50 output_path = generator.generate() generation_status[task_id] = { "status": "completed", "progress": 100, "message": "Video generated successfully", "output_path": str(output_path), "filename": output_path.name, } return output_path except Exception as e: generation_status[task_id] = { "status": "failed", "progress": 0, "message": str(e), } raise @app.post("/generate", response_model=GenerateResponse) async def generate_video(request: GenerateRequest): """Generate a quiz video synchronously.""" # Validate content exists in S3 for q in request.questions: if not storage.file_exists(f"audio/{q.opening_file}"): raise HTTPException( status_code=400, detail=f"Audio file not found: {q.opening_file}" ) # Check FFmpeg if not check_ffmpeg(): raise HTTPException( status_code=500, detail="FFmpeg is not available" ) try: # Run generation in thread pool to not block event loop loop = asyncio.get_event_loop() task_id = f"task_{id(request)}" output_path = await loop.run_in_executor( executor, run_generation, request, task_id, ) # Update last_usage for all used openings async with async_session_maker() as db: for q in request.questions: await db.execute( update(Opening) .where(Opening.audio_file == q.opening_file) .values(last_usage=datetime.now(timezone.utc)) ) await db.commit() return GenerateResponse( success=True, video_url=f"/videos/{output_path.name}", filename=output_path.name, ) except Exception as e: return GenerateResponse( success=False, error=str(e), ) @app.get("/download/{filename}") async def download_video(filename: str): """Download a generated video.""" video_path = settings.output_path / filename if not video_path.exists(): raise HTTPException(status_code=404, detail="Video not found") return FileResponse( path=str(video_path), filename=filename, media_type="video/mp4", ) @app.delete("/videos/{filename}") async def delete_video(filename: str): """Delete a generated video.""" video_path = settings.output_path / filename if not video_path.exists(): raise HTTPException(status_code=404, detail="Video not found") video_path.unlink() return {"message": "Video deleted successfully"} @app.get("/videos-list") async def list_videos(): """List all generated videos.""" videos = [] for f in settings.output_path.glob("*.mp4"): videos.append({ "filename": f.name, "size": f.stat().st_size, "url": f"/videos/{f.name}", "download_url": f"/download/{f.name}", }) return {"videos": sorted(videos, key=lambda x: x["filename"], reverse=True)} @app.post("/cache/clear") async def clear_cache(): """Clear the S3 file cache.""" storage.clear_cache() return {"message": "Cache cleared successfully"} # ============== Media Upload Endpoints ============== @app.post("/upload/audio") async def upload_audio(files: List[UploadFile] = File(...)): """Upload audio files to S3.""" results = [] for file in files: if not file.filename.lower().endswith((".mp3", ".wav", ".ogg", ".m4a")): results.append({"filename": file.filename, "success": False, "error": "Invalid file type"}) continue content = await file.read() success = storage.upload_audio(file.filename, content) results.append({"filename": file.filename, "success": success}) return {"results": results} @app.post("/upload/backgrounds") async def upload_backgrounds(files: List[UploadFile] = File(...)): """Upload background videos to S3.""" results = [] for file in files: if not file.filename.lower().endswith((".mp4", ".mov", ".avi")): results.append({"filename": file.filename, "success": False, "error": "Invalid file type"}) continue content = await file.read() success = storage.upload_background(file.filename, content) results.append({"filename": file.filename, "success": success}) return {"results": results} @app.post("/upload/posters") async def upload_posters(files: List[UploadFile] = File(...)): """Upload poster images to S3.""" results = [] for file in files: if not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")): results.append({"filename": file.filename, "success": False, "error": "Invalid file type"}) continue content = await file.read() success = storage.upload_poster(file.filename, content) results.append({"filename": file.filename, "success": success}) return {"results": results} @app.post("/upload/transitions") async def upload_transitions(files: List[UploadFile] = File(...)): """Upload transition sounds to S3.""" results = [] for file in files: if not file.filename.lower().endswith((".mp3", ".wav", ".ogg")): results.append({"filename": file.filename, "success": False, "error": "Invalid file type"}) continue content = await file.read() success = storage.upload_transition(file.filename, content) results.append({"filename": file.filename, "success": success}) return {"results": results} # ============== Media Delete Endpoints ============== @app.delete("/media/audio/{filename}") async def delete_audio(filename: str): """Delete audio file from S3.""" success = storage.delete_audio(filename) if not success: raise HTTPException(status_code=404, detail="File not found or delete failed") return {"message": "File deleted successfully"} @app.delete("/media/backgrounds/{filename}") async def delete_background(filename: str): """Delete background video from S3.""" success = storage.delete_background(filename) if not success: raise HTTPException(status_code=404, detail="File not found or delete failed") return {"message": "File deleted successfully"} @app.delete("/media/posters/{filename}") async def delete_poster(filename: str): """Delete poster image from S3.""" success = storage.delete_poster(filename) if not success: raise HTTPException(status_code=404, detail="File not found or delete failed") return {"message": "File deleted successfully"} @app.delete("/media/transitions/{filename}") async def delete_transition(filename: str): """Delete transition sound from S3.""" success = storage.delete_transition(filename) if not success: raise HTTPException(status_code=404, detail="File not found or delete failed") return {"message": "File deleted successfully"}