app v1
This commit is contained in:
378
backend/app/main.py
Normal file
378
backend/app/main.py
Normal file
@@ -0,0 +1,378 @@
|
||||
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 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")
|
||||
|
||||
# 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"}
|
||||
Reference in New Issue
Block a user