379 lines
12 KiB
Python
379 lines
12 KiB
Python
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"}
|