217 lines
8.2 KiB
Python
217 lines
8.2 KiB
Python
import json
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
|
|
|
from .. import database as db, config, jobs
|
|
from ..config import merge_campaign_settings_with_env
|
|
from ..errors import PipelineError
|
|
from ..pipeline.audio import extract_audio
|
|
from ..pipeline.transcribe import transcribe_and_diarize
|
|
|
|
|
|
def _dedup_path(path: Path) -> Path:
|
|
"""Append a counter suffix like ` (1)`, ` (2)` if the file exists."""
|
|
if not path.exists():
|
|
return path
|
|
stem = path.stem
|
|
suffix = path.suffix
|
|
parent = path.parent
|
|
counter = 1
|
|
while True:
|
|
new = parent / f"{stem} ({counter}){suffix}"
|
|
if not new.exists():
|
|
return new
|
|
counter += 1
|
|
|
|
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
|
|
|
# All uploaded/derived files live under config.UPLOAD_DIR / config.AUDIO_DIR /
|
|
# config.TRANSCRIPT_DIR (all under config.DATA_DIR, the /data volume) - never
|
|
# a hardcoded /app/storage path, which isn't backed by any volume the rest of
|
|
# the app agrees on.
|
|
|
|
|
|
def _row_to_dict(row) -> dict:
|
|
return dict(row)
|
|
|
|
|
|
@router.get("")
|
|
def list_sessions(campaign_id: str = None):
|
|
if campaign_id:
|
|
rows = db.get_conn().execute(
|
|
"SELECT * FROM sessions WHERE campaign_id = ? ORDER BY created_at DESC", (campaign_id,)
|
|
).fetchall()
|
|
else:
|
|
rows = db.get_conn().execute("SELECT * FROM sessions ORDER BY created_at DESC").fetchall()
|
|
sessions = []
|
|
for r in rows:
|
|
s = _row_to_dict(r)
|
|
job = db.get_conn().execute(
|
|
"SELECT * FROM jobs WHERE session_id = ? ORDER BY created_at DESC LIMIT 1", (s["id"],)
|
|
).fetchone()
|
|
s["latest_job"] = _row_to_dict(job) if job else None
|
|
sessions.append(s)
|
|
return sessions
|
|
|
|
|
|
@router.get("/{session_id}")
|
|
def get_session(session_id: str):
|
|
row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Session not found")
|
|
session = _row_to_dict(row)
|
|
job = db.get_conn().execute(
|
|
"SELECT * FROM jobs WHERE session_id = ? ORDER BY created_at DESC LIMIT 1", (session_id,)
|
|
).fetchone()
|
|
session["latest_job"] = _row_to_dict(job) if job else None
|
|
|
|
# Stats computed from transcript
|
|
if session.get("transcript_path"):
|
|
tp = Path(session["transcript_path"])
|
|
if tp.exists():
|
|
try:
|
|
data = json.loads(tp.read_text())
|
|
segments = data.get("segments", [])
|
|
if segments:
|
|
session["audio_duration"] = segments[-1]["end"]
|
|
session["word_count"] = sum(len(s.get("text", "").split()) for s in segments)
|
|
session["language"] = data.get("language")
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
return session
|
|
|
|
|
|
def _run_transcription(session_id: str, video_path: Path, audio_path: Path, transcript_path: Path, campaign_id: str | None = None):
|
|
cid = campaign_id or "default"
|
|
settings = merge_campaign_settings_with_env(db.get_campaign_settings(cid))
|
|
|
|
def run(progress_cb):
|
|
db.get_conn() # ensure thread-local connection exists in this worker thread
|
|
with db.tx() as conn:
|
|
conn.execute("UPDATE sessions SET status = 'transcribing' WHERE id = ?", (session_id,))
|
|
progress_cb("Extracting audio...")
|
|
try:
|
|
extract_audio(video_path, audio_path)
|
|
transcribe_and_diarize(
|
|
audio_path,
|
|
transcript_path,
|
|
model_size=settings.get("whisper_model", "medium"),
|
|
compute_type=settings.get("whisper_compute_type", "int8"),
|
|
hf_token=settings.get("hf_token", ""),
|
|
progress_cb=progress_cb,
|
|
)
|
|
except PipelineError:
|
|
with db.tx() as conn:
|
|
conn.execute("UPDATE sessions SET status = 'uploaded' WHERE id = ?", (session_id,))
|
|
raise
|
|
with db.tx() as conn:
|
|
conn.execute(
|
|
"UPDATE sessions SET audio_path = ?, transcript_path = ?, status = 'transcribed' WHERE id = ?",
|
|
(str(audio_path), str(transcript_path), session_id),
|
|
)
|
|
|
|
job_id = db.create_job(session_id, "transcribe")
|
|
jobs.submit(job_id, run, requires_gpu=True)
|
|
return job_id
|
|
|
|
|
|
@router.post("")
|
|
async def create_session(
|
|
name: str = Form(...),
|
|
file: UploadFile = File(None),
|
|
upload_path: str = Form(None),
|
|
campaign_id: str = Form(None),
|
|
):
|
|
session_id = db.new_id()
|
|
|
|
if campaign_id:
|
|
upload_dir = config.campaign_dir(campaign_id)
|
|
audio_dir = config.campaign_audio_dir(campaign_id)
|
|
transcript_dir = config.campaign_transcript_dir(campaign_id)
|
|
else:
|
|
upload_dir = config.UPLOAD_DIR
|
|
audio_dir = config.AUDIO_DIR
|
|
transcript_dir = config.TRANSCRIPT_DIR
|
|
|
|
if upload_path:
|
|
src = Path(upload_path)
|
|
if not src.exists():
|
|
raise HTTPException(400, f"Uploaded file not found at {upload_path}")
|
|
safe_filename = os.path.basename(src)
|
|
video_path = upload_dir / f"{session_id}_{safe_filename}"
|
|
shutil.move(str(src), str(video_path))
|
|
elif file:
|
|
safe_filename = os.path.basename(file.filename or f"{session_id}")
|
|
video_path = upload_dir / f"{session_id}_{safe_filename}"
|
|
with open(video_path, "wb") as buffer:
|
|
shutil.copyfileobj(file.file, buffer)
|
|
else:
|
|
raise HTTPException(400, "Either a file upload or upload_path is required")
|
|
|
|
with db.tx() as conn:
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, name, original_filename, video_path, campaign_id, status, created_at) "
|
|
"VALUES (?, ?, ?, ?, ?, 'uploaded', ?)",
|
|
(session_id, name, safe_filename, str(video_path), campaign_id, db.now()),
|
|
)
|
|
|
|
audio_path = audio_dir / f"{session_id}.wav"
|
|
transcript_path = transcript_dir / f"{session_id}.json"
|
|
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path, campaign_id)
|
|
|
|
return {"session_id": session_id, "job_id": job_id}
|
|
|
|
|
|
@router.post("/{session_id}/transcribe")
|
|
def retry_transcription(session_id: str):
|
|
"""Re-run transcription for a session, e.g. after a failed job."""
|
|
row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Session not found")
|
|
session = _row_to_dict(row)
|
|
video_path = Path(session["video_path"])
|
|
campaign_id = session.get("campaign_id")
|
|
if campaign_id:
|
|
audio_path = config.campaign_audio_dir(campaign_id) / f"{session_id}.wav"
|
|
transcript_path = config.campaign_transcript_dir(campaign_id) / f"{session_id}.json"
|
|
else:
|
|
audio_path = config.AUDIO_DIR / f"{session_id}.wav"
|
|
transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json"
|
|
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path, campaign_id)
|
|
return {"job_id": job_id}
|
|
|
|
|
|
@router.post("/upload-chunk")
|
|
async def upload_chunk(
|
|
file: UploadFile = File(...),
|
|
chunk_index: int = Form(...),
|
|
total_chunks: int = Form(...),
|
|
filename: str = Form(...),
|
|
upload_id: str = Form(...),
|
|
):
|
|
"""Assemble a large upload sent in chunks (bypasses nginx's client_max_body_size
|
|
for files bigger than that). Returns a filepath under UPLOAD_DIR that can later
|
|
be referenced when creating a session, for very large recordings."""
|
|
safe_filename = os.path.basename(filename)
|
|
temp_dir = config.UPLOAD_DIR / f"tmp_{upload_id}"
|
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
chunk_path = temp_dir / f"chunk_{chunk_index}"
|
|
with open(chunk_path, "wb") as buffer:
|
|
shutil.copyfileobj(file.file, buffer)
|
|
|
|
if len(os.listdir(temp_dir)) == total_chunks:
|
|
final_file_path = _dedup_path(config.UPLOAD_DIR / safe_filename)
|
|
with open(final_file_path, "wb") as final_file:
|
|
for i in range(total_chunks):
|
|
with open(temp_dir / f"chunk_{i}", "rb") as chunk_f:
|
|
final_file.write(chunk_f.read())
|
|
shutil.rmtree(temp_dir)
|
|
return {"status": "completed", "filepath": str(final_file_path), "filename": safe_filename}
|
|
|
|
return {"status": "chunk_received", "chunk_index": chunk_index}
|