This commit is contained in:
2025-12-30 17:37:14 +03:00
commit c33c5fd674
66 changed files with 10282 additions and 0 deletions

378
backend/app/main.py Normal file
View 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"}