Add campaign layer, GPU model caching, file browser cleanup
- Campaigns: new table, CRUD API, React context + provider
- Sessions: scoped to campaigns, paths under campaigns/{id}/
- File browser: scoped per campaign, removed copy/paste/autoPlay
- Sidebar: campaign selector dropdown at top
- Transcribe: GPU model cached/released via job counter
- Jobs: status text updates dynamically in real-time
- Auto-redirect: blocked when summarize job is active
This commit is contained in:
@@ -14,6 +14,20 @@ TRANSCRIPT_DIR = DATA_DIR / "transcriptions"
|
||||
NOTES_DIR = DATA_DIR / "notes"
|
||||
DB_PATH = DATA_DIR / "app.db"
|
||||
|
||||
CAMPAIGNS_DIR = DATA_DIR / "campaigns"
|
||||
|
||||
def campaign_dir(campaign_id: str) -> Path:
|
||||
return CAMPAIGNS_DIR / campaign_id
|
||||
|
||||
def campaign_audio_dir(campaign_id: str) -> Path:
|
||||
return campaign_dir(campaign_id) / "audio"
|
||||
|
||||
def campaign_transcript_dir(campaign_id: str) -> Path:
|
||||
return campaign_dir(campaign_id) / "transcriptions"
|
||||
|
||||
def campaign_notes_dir(campaign_id: str) -> Path:
|
||||
return campaign_dir(campaign_id) / "notes"
|
||||
|
||||
for d in (UPLOAD_DIR, AUDIO_DIR, TRANSCRIPT_DIR, NOTES_DIR):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -86,6 +86,26 @@ def init_db():
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS campaigns (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
try:
|
||||
conn.execute("ALTER TABLE sessions ADD COLUMN campaign_id TEXT REFERENCES campaigns(id) ON DELETE SET NULL")
|
||||
except Exception:
|
||||
pass
|
||||
default = conn.execute("SELECT id FROM campaigns WHERE id = 'default'").fetchone()
|
||||
if not default:
|
||||
conn.execute(
|
||||
"INSERT INTO campaigns (id, name, description, created_at) VALUES (?, ?, '', ?)",
|
||||
("default", "Default Campaign", now()),
|
||||
)
|
||||
conn.execute("UPDATE sessions SET campaign_id = 'default' WHERE campaign_id IS NULL")
|
||||
|
||||
# seed defaults if empty
|
||||
existing = {r["key"] for r in conn.execute("SELECT key FROM settings")}
|
||||
for k, v in config.DEFAULT_SETTINGS.items():
|
||||
@@ -140,3 +160,47 @@ def update_job(job_id: str, **fields):
|
||||
def get_job(job_id: str) -> dict | None:
|
||||
row = get_conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def create_campaign(name: str, description: str = "") -> dict:
|
||||
campaign_id = new_id()
|
||||
with tx() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO campaigns (id, name, description, created_at) VALUES (?, ?, ?, ?)",
|
||||
(campaign_id, name, description, now()),
|
||||
)
|
||||
from .config import campaign_audio_dir, campaign_transcript_dir, campaign_notes_dir
|
||||
campaign_audio_dir(campaign_id).mkdir(parents=True, exist_ok=True)
|
||||
campaign_transcript_dir(campaign_id).mkdir(parents=True, exist_ok=True)
|
||||
campaign_notes_dir(campaign_id).mkdir(parents=True, exist_ok=True)
|
||||
return get_campaign(campaign_id)
|
||||
|
||||
def list_campaigns() -> list[dict]:
|
||||
rows = get_conn().execute(
|
||||
"SELECT c.*, (SELECT COUNT(*) FROM sessions s WHERE s.campaign_id = c.id) as session_count "
|
||||
"FROM campaigns c ORDER BY c.created_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_campaign(campaign_id: str) -> dict | None:
|
||||
row = get_conn().execute(
|
||||
"SELECT c.*, (SELECT COUNT(*) FROM sessions s WHERE s.campaign_id = c.id) as session_count "
|
||||
"FROM campaigns c WHERE c.id = ?", (campaign_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def update_campaign(campaign_id: str, name: str | None = None, description: str | None = None) -> dict | None:
|
||||
fields = {}
|
||||
if name is not None: fields["name"] = name
|
||||
if description is not None: fields["description"] = description
|
||||
if not fields:
|
||||
return get_campaign(campaign_id)
|
||||
with tx() as conn:
|
||||
cols = ", ".join(f"{k} = ?" for k in fields)
|
||||
conn.execute(f"UPDATE campaigns SET {cols} WHERE id = ?", (*fields.values(), campaign_id))
|
||||
return get_campaign(campaign_id)
|
||||
|
||||
def delete_campaign(campaign_id: str) -> bool:
|
||||
with tx() as conn:
|
||||
conn.execute("UPDATE sessions SET campaign_id = NULL WHERE campaign_id = ?", (campaign_id,))
|
||||
conn.execute("DELETE FROM campaigns WHERE id = ?", (campaign_id,))
|
||||
return True
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"""
|
||||
Minimal background job runner.
|
||||
|
||||
GPU jobs use a shared counter so the Whisper model stays cached between
|
||||
back-to-back transcriptions and is only released when no more GPU work
|
||||
is queued.
|
||||
"""
|
||||
import threading
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
@@ -9,10 +14,23 @@ from .errors import PipelineError
|
||||
from .logging_config import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
_executor = ThreadPoolExecutor(max_workers=2) # transcription is GPU-bound anyway; no benefit to more workers
|
||||
_executor = ThreadPoolExecutor(max_workers=1)
|
||||
_gpu_jobs_queued = 0
|
||||
_gpu_lock = threading.Lock()
|
||||
|
||||
|
||||
def submit(job_id: str, fn, *args, **kwargs):
|
||||
def _unload_gpu():
|
||||
"""Release the cached Whisper model from GPU memory."""
|
||||
from .pipeline.transcribe import unload_models
|
||||
unload_models()
|
||||
|
||||
|
||||
def submit(job_id: str, fn, *args, requires_gpu=False, **kwargs):
|
||||
if requires_gpu:
|
||||
with _gpu_lock:
|
||||
global _gpu_jobs_queued
|
||||
_gpu_jobs_queued += 1
|
||||
|
||||
def _run():
|
||||
log.info("Job %s starting", job_id)
|
||||
db.update_job(job_id, status="running", progress="Starting...")
|
||||
@@ -37,5 +55,13 @@ def submit(job_id: str, fn, *args, **kwargs):
|
||||
error=f"Unexpected error: {e}", error_stage="unknown",
|
||||
error_detail=traceback.format_exc(),
|
||||
)
|
||||
finally:
|
||||
if requires_gpu:
|
||||
with _gpu_lock:
|
||||
global _gpu_jobs_queued
|
||||
_gpu_jobs_queued -= 1
|
||||
if _gpu_jobs_queued <= 0:
|
||||
log.info("No more GPU jobs queued — unloading Whisper model")
|
||||
_unload_gpu()
|
||||
|
||||
_executor.submit(_run)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
@@ -14,6 +16,7 @@ from .routers import jobs as jobs_router
|
||||
from .routers import models as models_router
|
||||
from .routers import diagnostics as diagnostics_router
|
||||
from .routers import files as files_router
|
||||
from .routers import campaigns as campaigns_router
|
||||
|
||||
setup_logging()
|
||||
log = get_logger(__name__)
|
||||
@@ -54,11 +57,15 @@ app.include_router(jobs_router.router)
|
||||
app.include_router(models_router.router)
|
||||
app.include_router(diagnostics_router.router)
|
||||
app.include_router(files_router.router)
|
||||
app.include_router(campaigns_router.router)
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/audio")
|
||||
def get_audio(session_id: str):
|
||||
audio_path = config.AUDIO_DIR / f"{session_id}.wav"
|
||||
row = db.get_conn().execute("SELECT audio_path FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
if not row or not row["audio_path"]:
|
||||
raise HTTPException(404, "Session not found or audio not available")
|
||||
audio_path = Path(row["audio_path"])
|
||||
if not audio_path.exists():
|
||||
raise HTTPException(404, "Audio not available for this session")
|
||||
return FileResponse(audio_path, media_type="audio/wav")
|
||||
|
||||
@@ -13,6 +13,53 @@ from ..logging_config import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# Module-level model cache — kept alive across jobs so back-to-back
|
||||
# transcriptions don't reload from disk each time.
|
||||
_whisper_model = None
|
||||
_whisper_config = {} # {"model_size": str, "compute_type": str, "device": str}
|
||||
|
||||
|
||||
def _get_model(model_size: str, compute_type: str, device: str):
|
||||
global _whisper_model, _whisper_config
|
||||
if (
|
||||
_whisper_model is not None
|
||||
and _whisper_config.get("model_size") == model_size
|
||||
and _whisper_config.get("compute_type") == compute_type
|
||||
and _whisper_config.get("device") == device
|
||||
):
|
||||
log.info("Reusing cached Whisper model (%s, %s, %s)", model_size, compute_type, device)
|
||||
return _whisper_model
|
||||
log.info("Loading Whisper model (%s, %s, %s)", model_size, compute_type, device)
|
||||
_unload_cached()
|
||||
_whisper_model = whisperx.load_model(model_size, device, compute_type=compute_type)
|
||||
_whisper_config = {"model_size": model_size, "compute_type": compute_type, "device": device}
|
||||
return _whisper_model
|
||||
|
||||
|
||||
def _unload_cached():
|
||||
global _whisper_model, _whisper_config
|
||||
if _whisper_model is not None:
|
||||
log.info("Unloading cached Whisper model")
|
||||
del _whisper_model
|
||||
_whisper_model = None
|
||||
_whisper_config = {}
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.synchronize()
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.reset_peak_memory_stats()
|
||||
|
||||
|
||||
def unload_models():
|
||||
"""Release all GPU memory held by transcription models.
|
||||
Called by the job runner when no more GPU jobs are queued."""
|
||||
_unload_cached()
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.synchronize()
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.reset_peak_memory_stats()
|
||||
|
||||
|
||||
def transcribe_and_diarize(
|
||||
audio_path: Path,
|
||||
@@ -33,7 +80,7 @@ def transcribe_and_diarize(
|
||||
|
||||
report("Loading transcription model...")
|
||||
try:
|
||||
model = whisperx.load_model(model_size, device, compute_type=compute_type)
|
||||
model = _get_model(model_size, compute_type, device)
|
||||
except RuntimeError as e:
|
||||
if "out of memory" in str(e).lower():
|
||||
raise TranscriptionError(
|
||||
@@ -59,22 +106,21 @@ def transcribe_and_diarize(
|
||||
raise TranscriptionError(f"Transcription failed: {e}", cause=e)
|
||||
except Exception as e:
|
||||
raise TranscriptionError(f"Transcription failed: {e}", cause=e)
|
||||
finally:
|
||||
del model
|
||||
gc.collect()
|
||||
if device == "cuda":
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
try:
|
||||
report("Aligning...")
|
||||
align_model, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
|
||||
result = whisperx.align(result["segments"], align_model, metadata, audio, device, return_char_alignments=False)
|
||||
del align_model
|
||||
except Exception as e:
|
||||
raise TranscriptionError(f"Alignment step failed: {e}", cause=e)
|
||||
finally:
|
||||
try:
|
||||
del align_model
|
||||
except NameError:
|
||||
pass
|
||||
gc.collect()
|
||||
if device == "cuda":
|
||||
torch.cuda.empty_cache()
|
||||
except Exception as e:
|
||||
raise TranscriptionError(f"Alignment step failed: {e}", cause=e)
|
||||
|
||||
if hf_token:
|
||||
try:
|
||||
@@ -91,6 +137,14 @@ def transcribe_and_diarize(
|
||||
f"your HF token belongs to.\n{msg}", cause=e
|
||||
)
|
||||
raise DiarizationError(f"Diarization failed: {msg}", cause=e)
|
||||
finally:
|
||||
try:
|
||||
del diarize_model
|
||||
except NameError:
|
||||
pass
|
||||
gc.collect()
|
||||
if device == "cuda":
|
||||
torch.cuda.empty_cache()
|
||||
else:
|
||||
report("No HF token set - skipping speaker diarization (all speech will be unattributed)")
|
||||
|
||||
|
||||
47
backend/app/routers/campaigns.py
Normal file
47
backend/app/routers/campaigns.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from .. import database as db
|
||||
|
||||
router = APIRouter(prefix="/api/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_campaigns():
|
||||
return db.list_campaigns()
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_campaign(body: dict):
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name is required")
|
||||
desc = body.get("description", "").strip()
|
||||
return db.create_campaign(name, desc)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}")
|
||||
def get_campaign(campaign_id: str):
|
||||
campaign = db.get_campaign(campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(404, "Campaign not found")
|
||||
return campaign
|
||||
|
||||
|
||||
@router.put("/{campaign_id}")
|
||||
def update_campaign(campaign_id: str, body: dict):
|
||||
if not db.get_campaign(campaign_id):
|
||||
raise HTTPException(404, "Campaign not found")
|
||||
updated = db.update_campaign(
|
||||
campaign_id,
|
||||
name=body.get("name"),
|
||||
description=body.get("description"),
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/{campaign_id}")
|
||||
def delete_campaign(campaign_id: str):
|
||||
if not db.get_campaign(campaign_id):
|
||||
raise HTTPException(404, "Campaign not found")
|
||||
db.delete_campaign(campaign_id)
|
||||
return {"ok": True}
|
||||
@@ -1,4 +1,3 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
@@ -11,25 +10,14 @@ router = APIRouter(prefix="/api/files", tags=["files"])
|
||||
DATA = config.DATA_DIR.resolve()
|
||||
|
||||
|
||||
def _safe_path(path_str: str) -> Path:
|
||||
resolved = (DATA / path_str).resolve()
|
||||
if not str(resolved).startswith(str(DATA)):
|
||||
def _safe_path(path_str: str, campaign_id: str | None = None) -> Path:
|
||||
root = (config.campaign_dir(campaign_id) if campaign_id else DATA).resolve()
|
||||
resolved = (root / path_str).resolve()
|
||||
if not str(resolved).startswith(str(root)):
|
||||
raise HTTPException(400, "Path traversal denied")
|
||||
return resolved
|
||||
|
||||
|
||||
def _auto_dest(source: Path) -> Path:
|
||||
parent = source.parent
|
||||
stem = source.stem
|
||||
suffix = source.suffix
|
||||
candidate = parent / f"{stem}_copy{suffix}"
|
||||
n = 2
|
||||
while candidate.exists():
|
||||
candidate = parent / f"{stem}_copy_{n}{suffix}"
|
||||
n += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _auto_name(parent: Path, name: str) -> Path:
|
||||
p = parent / name
|
||||
if not p.exists():
|
||||
@@ -53,22 +41,29 @@ def _enrich(entries, conn):
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
def browse(path: str = ""):
|
||||
def browse(path: str = "", campaign_id: str = None):
|
||||
clean = path.strip("/")
|
||||
entries = []
|
||||
conn = db.get_conn()
|
||||
base = config.campaign_dir(campaign_id) if campaign_id else DATA
|
||||
|
||||
# Root level — show the three directories
|
||||
if not clean:
|
||||
for name in sorted(["audio", "transcriptions", "notes"]):
|
||||
entries.append({"name": name, "type": "dir", "path": name})
|
||||
return {"entries": entries, "current_path": "", "parent_path": None}
|
||||
|
||||
# Virtual "notes" directory — list from DB
|
||||
if clean == "notes":
|
||||
rows = conn.execute(
|
||||
"SELECT session_id, dm_notes, player_recap, generated_at FROM notes ORDER BY generated_at DESC"
|
||||
).fetchall()
|
||||
if campaign_id:
|
||||
rows = conn.execute(
|
||||
"""SELECT n.session_id, n.dm_notes, n.player_recap, n.generated_at
|
||||
FROM notes n JOIN sessions s ON n.session_id = s.id
|
||||
WHERE s.campaign_id = ? ORDER BY n.generated_at DESC""",
|
||||
(campaign_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT session_id, dm_notes, player_recap, generated_at FROM notes ORDER BY generated_at DESC"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
for kind, text in (("player_recap", r["player_recap"]), ("dm_notes", r["dm_notes"])):
|
||||
if not text:
|
||||
@@ -85,15 +80,16 @@ def browse(path: str = ""):
|
||||
_enrich(entries, conn)
|
||||
return {"entries": entries, "current_path": "notes", "parent_path": ""}
|
||||
|
||||
# Real directory on disk
|
||||
resolved = DATA / clean
|
||||
resolved = (base / clean).resolve()
|
||||
if not str(resolved).startswith(str(base.resolve())):
|
||||
raise HTTPException(400, "Path traversal denied")
|
||||
if not resolved.exists() or not resolved.is_dir():
|
||||
raise HTTPException(404, "Directory not found")
|
||||
|
||||
for f in sorted(resolved.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
||||
if f.name.startswith("tmp_"):
|
||||
continue
|
||||
rel = str(f.relative_to(DATA))
|
||||
rel = str(f.relative_to(base))
|
||||
entry = {"name": f.name, "type": "dir" if f.is_dir() else "file", "path": rel}
|
||||
if not f.is_dir():
|
||||
entry["size"] = f.stat().st_size
|
||||
@@ -104,7 +100,7 @@ def browse(path: str = ""):
|
||||
|
||||
parts = clean.split("/")
|
||||
parent = "/".join(parts[:-1]) if len(parts) > 1 else ""
|
||||
return {"entries": entries, "current_path": clean, "parent_path": parent or None}
|
||||
return {"entries": entries, "current_path": clean, "parent_path": parent}
|
||||
|
||||
|
||||
_MIME_MAP: dict[str, str] = {
|
||||
@@ -120,8 +116,7 @@ _MIME_MAP: dict[str, str] = {
|
||||
|
||||
|
||||
@router.get("/view")
|
||||
def view_file(path: str):
|
||||
# Virtual notes file — serve from DB
|
||||
def view_file(path: str, campaign_id: str = None):
|
||||
if path.startswith("notes/"):
|
||||
parts = Path(path).stem.split("_")
|
||||
session_id = parts[0]
|
||||
@@ -133,8 +128,7 @@ def view_file(path: str):
|
||||
text = row[kind] if kind in ("dm_notes", "player_recap") else (row["player_recap"] or row["dm_notes"] or "")
|
||||
return Response(content=text, media_type="text/plain; charset=utf-8", headers={"Content-Disposition": "inline"})
|
||||
|
||||
# Real file on disk
|
||||
file_path = _safe_path(path)
|
||||
file_path = _safe_path(path, campaign_id)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
@@ -143,8 +137,8 @@ def view_file(path: str):
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
def download_file(path: str):
|
||||
file_path = _safe_path(path)
|
||||
def download_file(path: str, campaign_id: str = None):
|
||||
file_path = _safe_path(path, campaign_id)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
return FileResponse(file_path, filename=file_path.name)
|
||||
@@ -157,23 +151,26 @@ async def upload_file(request: Request):
|
||||
if not file_field or not hasattr(file_field, "filename") or not file_field.filename:
|
||||
raise HTTPException(400, "No file provided")
|
||||
dest_dir_str = form.get("dir", "audio")
|
||||
dest_dir = _safe_path(dest_dir_str)
|
||||
campaign_id = form.get("campaign_id")
|
||||
dest_dir = _safe_path(dest_dir_str, campaign_id)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = _auto_name(dest_dir, file_field.filename)
|
||||
with open(dest, "wb") as f:
|
||||
content = await file_field.read()
|
||||
f.write(content)
|
||||
return {"path": str(dest.relative_to(DATA))}
|
||||
root = config.campaign_dir(campaign_id) if campaign_id else DATA
|
||||
return {"path": str(dest.relative_to(root))}
|
||||
|
||||
|
||||
@router.delete("")
|
||||
def delete_files(body: dict):
|
||||
paths = body.get("paths", [])
|
||||
campaign_id = body.get("campaign_id")
|
||||
deleted = []
|
||||
errors = []
|
||||
for p in paths:
|
||||
try:
|
||||
fp = _safe_path(p)
|
||||
fp = _safe_path(p, campaign_id)
|
||||
if fp.exists():
|
||||
fp.unlink()
|
||||
deleted.append(p)
|
||||
@@ -195,30 +192,3 @@ def delete_files(body: dict):
|
||||
except Exception as e:
|
||||
errors.append({"path": p, "error": str(e)})
|
||||
return {"deleted": deleted, "errors": errors}
|
||||
|
||||
|
||||
@router.post("/copy")
|
||||
def copy_file(body: dict):
|
||||
source_str = body.get("source")
|
||||
dest_dir_str = body.get("dest_dir")
|
||||
if not source_str:
|
||||
raise HTTPException(400, "source is required")
|
||||
source = _safe_path(source_str)
|
||||
if not source.exists():
|
||||
raise HTTPException(404, "Source file not found")
|
||||
|
||||
if dest_dir_str:
|
||||
dest_dir = _safe_path(dest_dir_str)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = _auto_name(dest_dir, source.name)
|
||||
else:
|
||||
dest = _auto_dest(source)
|
||||
|
||||
if dest.exists():
|
||||
raise HTTPException(409, f"Destination already exists: {dest.name}")
|
||||
|
||||
shutil.copy2(source, dest)
|
||||
return {
|
||||
"source": str(source.relative_to(DATA)),
|
||||
"dest": str(dest.relative_to(DATA)),
|
||||
}
|
||||
|
||||
@@ -38,8 +38,13 @@ def _row_to_dict(row) -> dict:
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_sessions():
|
||||
rows = db.get_conn().execute("SELECT * FROM sessions ORDER BY created_at DESC").fetchall()
|
||||
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)
|
||||
@@ -108,24 +113,38 @@ def _run_transcription(session_id: str, video_path: Path, audio_path: Path, tran
|
||||
)
|
||||
|
||||
job_id = db.create_job(session_id, "transcribe")
|
||||
jobs.submit(job_id, run)
|
||||
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)):
|
||||
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 = config.UPLOAD_DIR / f"{session_id}_{safe_filename}"
|
||||
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 = config.UPLOAD_DIR / f"{session_id}_{safe_filename}"
|
||||
video_path = upload_dir / f"{session_id}_{safe_filename}"
|
||||
with open(video_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
else:
|
||||
@@ -133,13 +152,13 @@ async def create_session(name: str = Form(...), file: UploadFile = File(None), u
|
||||
|
||||
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()),
|
||||
"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 = config.AUDIO_DIR / f"{session_id}.wav"
|
||||
transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json"
|
||||
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)
|
||||
|
||||
return {"session_id": session_id, "job_id": job_id}
|
||||
@@ -153,8 +172,13 @@ def retry_transcription(session_id: str):
|
||||
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"
|
||||
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)
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user