Initial commit: Nat20 Notes — TTRPG session transcription & summarization
This commit is contained in:
190
backend/app/routers/sessions.py
Normal file
190
backend/app/routers/sessions.py
Normal file
@@ -0,0 +1,190 @@
|
||||
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 ..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():
|
||||
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):
|
||||
settings = db.get_settings()
|
||||
|
||||
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)
|
||||
return job_id
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_session(name: str = Form(...), file: UploadFile = File(None), upload_path: str = Form(None)):
|
||||
session_id = db.new_id()
|
||||
|
||||
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 = config.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 = config.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, status, created_at) "
|
||||
"VALUES (?, ?, ?, ?, 'uploaded', ?)",
|
||||
(session_id, name, safe_filename, str(video_path), db.now()),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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"])
|
||||
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)
|
||||
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}
|
||||
Reference in New Issue
Block a user