Files
Nat20-Notes/backend/app/routers/sessions.py
KansaiGaijin 6c760b5e2e
Some checks failed
Build and Push / build (push) Has been cancelled
Preload audio as Blob for instant seeking; generate Opus playback file; delete original upload after extraction
- Download full audio as Blob on mount so seeking is instant (no network range-requests)
- Remove readyState guard from playAt() that was silently blocking seek attempts
- Add disabled/ready state to SessionReel for reliable click-to-seek
- Generate 48kbps Opus playback file during extraction (~65MB for 3hrs vs 1.5GB lossless)
- Serve Opus first in audio endpoint, then original upload, then extracted WAV
- Delete original upload after successful transcription to reclaim storage
- Reorder extract_audio checks so retry works without the original file
- Fix Back links on Speakers and Notes pages to go to home
2026-07-30 10:38:10 +12:00

228 lines
8.7 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, generate_playback_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)
generate_playback_audio(video_path, audio_path.with_suffix(".opus"))
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),
)
# Original upload no longer needed — Opus serves playback, WAV serves
# re-transcription. Reclaim storage.
if video_path.exists():
try:
video_path.unlink()
with db.tx() as conn:
conn.execute("UPDATE sessions SET video_path = NULL WHERE id = ?", (session_id,))
except OSError:
pass # non-fatal
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}