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.
This commit is contained in:
2026-07-10 22:42:23 +02:00
commit 6851b26923
43 changed files with 3390 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
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")