Files
bornetime/backend/app/api/videos.py
T
oval 6851b26923 Initial commit: Børnetime app scaffold
FastAPI + Celery + Svelte app for English→Danish video translation
with voice cloning. Includes diarization, STT, translation, TTS,
and audio mixing pipeline.
2026-07-10 22:42:23 +02:00

219 lines
7.4 KiB
Python

import os
import logging
import aiofiles
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..models.database import get_session
from ..models.tables import Video, Segment, Job, VideoStatus, JobStatus
from ..models.schemas import (
VideoOut,
VideoListOut,
SegmentOut,
SegmentUpdate,
TranscriptOut,
ProcessResponse,
)
from ..services.video_utils import get_video_duration
from ..config import settings
from ..tasks.process import process_video
from ..tasks.generate import generate_audio
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("", response_model=VideoListOut)
async def list_videos(session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Video).order_by(Video.created_at.desc()))
videos = result.scalars().all()
return VideoListOut(videos=[VideoOut.model_validate(v) for v in videos])
@router.get("/{video_id}", response_model=VideoOut)
async def get_video(video_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Video).where(Video.id == video_id))
video = result.scalar_one_or_none()
if not video:
raise HTTPException(404, "Video not found")
return VideoOut.model_validate(video)
@router.post("/upload", response_model=VideoOut)
async def upload_video(
file: UploadFile = File(...), session: AsyncSession = Depends(get_session)
):
if not file.filename:
raise HTTPException(400, "No filename")
allowed = {".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v"}
ext = os.path.splitext(file.filename)[1].lower()
if ext not in allowed:
raise HTTPException(
400, f"Unsupported format: {ext}. Allowed: {', '.join(allowed)}"
)
dest = os.path.join(settings.video_dir, file.filename)
async with aiofiles.open(dest, "wb") as f:
while chunk := await file.read(64 * 1024):
await f.write(chunk)
duration = get_video_duration(dest)
video = Video(
filename=file.filename,
filepath=dest,
duration=duration,
status=VideoStatus.uploaded.value,
)
session.add(video)
await session.commit()
await session.refresh(video)
return VideoOut.model_validate(video)
@router.delete("/{video_id}")
async def delete_video(video_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Video).where(Video.id == video_id))
video = result.scalar_one_or_none()
if not video:
raise HTTPException(404, "Video not found")
if os.path.exists(video.filepath):
os.remove(video.filepath)
if video.output_path:
output_full = os.path.join(settings.video_dir, video.output_path)
if os.path.exists(output_full):
os.remove(output_full)
await session.delete(video)
await session.commit()
return {"ok": True}
@router.post("/{video_id}/process", response_model=ProcessResponse)
async def start_processing(video_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Video).where(Video.id == video_id))
video = result.scalar_one_or_none()
if not video:
raise HTTPException(404, "Video not found")
if video.status not in (VideoStatus.uploaded.value, VideoStatus.error.value):
raise HTTPException(400, f"Cannot process video in status '{video.status}'")
existing_job = await session.execute(
select(Job).where(
Job.video_id == video_id,
Job.job_type == "process",
Job.status.in_([JobStatus.pending.value, JobStatus.running.value]),
)
)
if existing_job.scalar_one_or_none():
raise HTTPException(400, "A process job is already running for this video")
job = Job(video_id=video_id, job_type="process", status=JobStatus.pending.value)
session.add(job)
await session.commit()
await session.refresh(job)
video.status = VideoStatus.queued.value
await session.commit()
process_video.delay(video_id=video_id, job_id=job.id)
return ProcessResponse(job_id=job.id, message="Processing started")
@router.get("/{video_id}/transcript", response_model=TranscriptOut)
async def get_transcript(video_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(Segment).where(Segment.video_id == video_id).order_by(Segment.start_time)
)
segments = result.scalars().all()
return TranscriptOut(
video_id=video_id,
segments=[SegmentOut.model_validate(s) for s in segments],
)
@router.put("/{video_id}/transcript/{segment_id}", response_model=SegmentOut)
async def update_segment(
video_id: int,
segment_id: int,
update: SegmentUpdate,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(Segment).where(Segment.id == segment_id, Segment.video_id == video_id)
)
seg = result.scalar_one_or_none()
if not seg:
raise HTTPException(404, "Segment not found")
if update.english_text is not None:
seg.english_text = update.english_text
if update.danish_text is not None:
seg.danish_text = update.danish_text
if update.speaker_label is not None:
seg.speaker_label = update.speaker_label
await session.commit()
await session.refresh(seg)
return SegmentOut.model_validate(seg)
@router.get("/{video_id}/file")
async def stream_video(video_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Video).where(Video.id == video_id))
video = result.scalar_one_or_none()
if not video:
raise HTTPException(404, "Video not found")
if not os.path.exists(video.filepath):
raise HTTPException(404, "Video file not found on disk")
return FileResponse(video.filepath, media_type="video/mp4", filename=video.filename)
@router.get("/file/{filename}")
async def stream_output(filename: str):
full_path = os.path.join(settings.video_dir, filename)
if not os.path.exists(full_path):
raise HTTPException(404, "File not found")
return FileResponse(full_path)
@router.post("/{video_id}/generate", response_model=ProcessResponse)
async def start_generation(video_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Video).where(Video.id == video_id))
video = result.scalar_one_or_none()
if not video:
raise HTTPException(404, "Video not found")
if video.status != VideoStatus.review.value:
raise HTTPException(400, f"Cannot generate audio in status '{video.status}'")
existing_job = await session.execute(
select(Job).where(
Job.video_id == video_id,
Job.job_type == "generate",
Job.status.in_([JobStatus.pending.value, JobStatus.running.value]),
)
)
if existing_job.scalar_one_or_none():
raise HTTPException(400, "A generate job is already running for this video")
job = Job(video_id=video_id, job_type="generate", status=JobStatus.pending.value)
session.add(job)
await session.commit()
await session.refresh(job)
video.status = VideoStatus.generating.value
await session.commit()
generate_audio.delay(video_id=video_id, job_id=job.id)
return ProcessResponse(job_id=job.id, message="Audio generation started")