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:
KansaiGaijin
2026-07-10 21:41:29 +12:00
parent db19cfd05e
commit bc7ac32de3
19 changed files with 702 additions and 182 deletions

View File

@@ -14,6 +14,20 @@ TRANSCRIPT_DIR = DATA_DIR / "transcriptions"
NOTES_DIR = DATA_DIR / "notes" NOTES_DIR = DATA_DIR / "notes"
DB_PATH = DATA_DIR / "app.db" 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): for d in (UPLOAD_DIR, AUDIO_DIR, TRANSCRIPT_DIR, NOTES_DIR):
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)

View File

@@ -86,6 +86,26 @@ def init_db():
updated_at REAL NOT NULL 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 # seed defaults if empty
existing = {r["key"] for r in conn.execute("SELECT key FROM settings")} existing = {r["key"] for r in conn.execute("SELECT key FROM settings")}
for k, v in config.DEFAULT_SETTINGS.items(): 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: def get_job(job_id: str) -> dict | None:
row = get_conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() row = get_conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
return dict(row) if row else None 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

View File

@@ -1,6 +1,11 @@
""" """
Minimal background job runner. 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 import traceback
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -9,10 +14,23 @@ from .errors import PipelineError
from .logging_config import get_logger from .logging_config import get_logger
log = get_logger(__name__) 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(): def _run():
log.info("Job %s starting", job_id) log.info("Job %s starting", job_id)
db.update_job(job_id, status="running", progress="Starting...") 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=f"Unexpected error: {e}", error_stage="unknown",
error_detail=traceback.format_exc(), 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) _executor.submit(_run)

View File

@@ -1,3 +1,5 @@
from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse 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 models as models_router
from .routers import diagnostics as diagnostics_router from .routers import diagnostics as diagnostics_router
from .routers import files as files_router from .routers import files as files_router
from .routers import campaigns as campaigns_router
setup_logging() setup_logging()
log = get_logger(__name__) log = get_logger(__name__)
@@ -54,11 +57,15 @@ app.include_router(jobs_router.router)
app.include_router(models_router.router) app.include_router(models_router.router)
app.include_router(diagnostics_router.router) app.include_router(diagnostics_router.router)
app.include_router(files_router.router) app.include_router(files_router.router)
app.include_router(campaigns_router.router)
@app.get("/api/sessions/{session_id}/audio") @app.get("/api/sessions/{session_id}/audio")
def get_audio(session_id: str): 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(): if not audio_path.exists():
raise HTTPException(404, "Audio not available for this session") raise HTTPException(404, "Audio not available for this session")
return FileResponse(audio_path, media_type="audio/wav") return FileResponse(audio_path, media_type="audio/wav")

View File

@@ -13,6 +13,53 @@ from ..logging_config import get_logger
log = get_logger(__name__) 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( def transcribe_and_diarize(
audio_path: Path, audio_path: Path,
@@ -33,7 +80,7 @@ def transcribe_and_diarize(
report("Loading transcription model...") report("Loading transcription model...")
try: try:
model = whisperx.load_model(model_size, device, compute_type=compute_type) model = _get_model(model_size, compute_type, device)
except RuntimeError as e: except RuntimeError as e:
if "out of memory" in str(e).lower(): if "out of memory" in str(e).lower():
raise TranscriptionError( raise TranscriptionError(
@@ -59,22 +106,21 @@ def transcribe_and_diarize(
raise TranscriptionError(f"Transcription failed: {e}", cause=e) raise TranscriptionError(f"Transcription failed: {e}", cause=e)
except Exception as e: except Exception as e:
raise TranscriptionError(f"Transcription failed: {e}", cause=e) raise TranscriptionError(f"Transcription failed: {e}", cause=e)
finally:
del model
gc.collect()
if device == "cuda":
torch.cuda.empty_cache()
try: try:
report("Aligning...") report("Aligning...")
align_model, metadata = whisperx.load_align_model(language_code=result["language"], device=device) 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) result = whisperx.align(result["segments"], align_model, metadata, audio, device, return_char_alignments=False)
except Exception as e:
raise TranscriptionError(f"Alignment step failed: {e}", cause=e)
finally:
try:
del align_model del align_model
except NameError:
pass
gc.collect() gc.collect()
if device == "cuda": if device == "cuda":
torch.cuda.empty_cache() torch.cuda.empty_cache()
except Exception as e:
raise TranscriptionError(f"Alignment step failed: {e}", cause=e)
if hf_token: if hf_token:
try: try:
@@ -91,6 +137,14 @@ def transcribe_and_diarize(
f"your HF token belongs to.\n{msg}", cause=e f"your HF token belongs to.\n{msg}", cause=e
) )
raise DiarizationError(f"Diarization failed: {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: else:
report("No HF token set - skipping speaker diarization (all speech will be unattributed)") report("No HF token set - skipping speaker diarization (all speech will be unattributed)")

View 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}

View File

@@ -1,4 +1,3 @@
import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
@@ -11,25 +10,14 @@ router = APIRouter(prefix="/api/files", tags=["files"])
DATA = config.DATA_DIR.resolve() DATA = config.DATA_DIR.resolve()
def _safe_path(path_str: str) -> Path: def _safe_path(path_str: str, campaign_id: str | None = None) -> Path:
resolved = (DATA / path_str).resolve() root = (config.campaign_dir(campaign_id) if campaign_id else DATA).resolve()
if not str(resolved).startswith(str(DATA)): resolved = (root / path_str).resolve()
if not str(resolved).startswith(str(root)):
raise HTTPException(400, "Path traversal denied") raise HTTPException(400, "Path traversal denied")
return resolved 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: def _auto_name(parent: Path, name: str) -> Path:
p = parent / name p = parent / name
if not p.exists(): if not p.exists():
@@ -53,19 +41,26 @@ def _enrich(entries, conn):
@router.get("/browse") @router.get("/browse")
def browse(path: str = ""): def browse(path: str = "", campaign_id: str = None):
clean = path.strip("/") clean = path.strip("/")
entries = [] entries = []
conn = db.get_conn() conn = db.get_conn()
base = config.campaign_dir(campaign_id) if campaign_id else DATA
# Root level — show the three directories
if not clean: if not clean:
for name in sorted(["audio", "transcriptions", "notes"]): for name in sorted(["audio", "transcriptions", "notes"]):
entries.append({"name": name, "type": "dir", "path": name}) entries.append({"name": name, "type": "dir", "path": name})
return {"entries": entries, "current_path": "", "parent_path": None} return {"entries": entries, "current_path": "", "parent_path": None}
# Virtual "notes" directory — list from DB
if clean == "notes": if clean == "notes":
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( rows = conn.execute(
"SELECT session_id, dm_notes, player_recap, generated_at FROM notes ORDER BY generated_at DESC" "SELECT session_id, dm_notes, player_recap, generated_at FROM notes ORDER BY generated_at DESC"
).fetchall() ).fetchall()
@@ -85,15 +80,16 @@ def browse(path: str = ""):
_enrich(entries, conn) _enrich(entries, conn)
return {"entries": entries, "current_path": "notes", "parent_path": ""} return {"entries": entries, "current_path": "notes", "parent_path": ""}
# Real directory on disk resolved = (base / clean).resolve()
resolved = DATA / clean if not str(resolved).startswith(str(base.resolve())):
raise HTTPException(400, "Path traversal denied")
if not resolved.exists() or not resolved.is_dir(): if not resolved.exists() or not resolved.is_dir():
raise HTTPException(404, "Directory not found") raise HTTPException(404, "Directory not found")
for f in sorted(resolved.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())): for f in sorted(resolved.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
if f.name.startswith("tmp_"): if f.name.startswith("tmp_"):
continue 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} entry = {"name": f.name, "type": "dir" if f.is_dir() else "file", "path": rel}
if not f.is_dir(): if not f.is_dir():
entry["size"] = f.stat().st_size entry["size"] = f.stat().st_size
@@ -104,7 +100,7 @@ def browse(path: str = ""):
parts = clean.split("/") parts = clean.split("/")
parent = "/".join(parts[:-1]) if len(parts) > 1 else "" 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] = { _MIME_MAP: dict[str, str] = {
@@ -120,8 +116,7 @@ _MIME_MAP: dict[str, str] = {
@router.get("/view") @router.get("/view")
def view_file(path: str): def view_file(path: str, campaign_id: str = None):
# Virtual notes file — serve from DB
if path.startswith("notes/"): if path.startswith("notes/"):
parts = Path(path).stem.split("_") parts = Path(path).stem.split("_")
session_id = parts[0] 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 "") 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"}) return Response(content=text, media_type="text/plain; charset=utf-8", headers={"Content-Disposition": "inline"})
# Real file on disk file_path = _safe_path(path, campaign_id)
file_path = _safe_path(path)
if not file_path.exists(): if not file_path.exists():
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
@@ -143,8 +137,8 @@ def view_file(path: str):
@router.get("/download") @router.get("/download")
def download_file(path: str): def download_file(path: str, campaign_id: str = None):
file_path = _safe_path(path) file_path = _safe_path(path, campaign_id)
if not file_path.exists(): if not file_path.exists():
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
return FileResponse(file_path, filename=file_path.name) 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: if not file_field or not hasattr(file_field, "filename") or not file_field.filename:
raise HTTPException(400, "No file provided") raise HTTPException(400, "No file provided")
dest_dir_str = form.get("dir", "audio") 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_dir.mkdir(parents=True, exist_ok=True)
dest = _auto_name(dest_dir, file_field.filename) dest = _auto_name(dest_dir, file_field.filename)
with open(dest, "wb") as f: with open(dest, "wb") as f:
content = await file_field.read() content = await file_field.read()
f.write(content) 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("") @router.delete("")
def delete_files(body: dict): def delete_files(body: dict):
paths = body.get("paths", []) paths = body.get("paths", [])
campaign_id = body.get("campaign_id")
deleted = [] deleted = []
errors = [] errors = []
for p in paths: for p in paths:
try: try:
fp = _safe_path(p) fp = _safe_path(p, campaign_id)
if fp.exists(): if fp.exists():
fp.unlink() fp.unlink()
deleted.append(p) deleted.append(p)
@@ -195,30 +192,3 @@ def delete_files(body: dict):
except Exception as e: except Exception as e:
errors.append({"path": p, "error": str(e)}) errors.append({"path": p, "error": str(e)})
return {"deleted": deleted, "errors": errors} 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)),
}

View File

@@ -38,7 +38,12 @@ def _row_to_dict(row) -> dict:
@router.get("") @router.get("")
def list_sessions(): 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() rows = db.get_conn().execute("SELECT * FROM sessions ORDER BY created_at DESC").fetchall()
sessions = [] sessions = []
for r in rows: for r in rows:
@@ -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") job_id = db.create_job(session_id, "transcribe")
jobs.submit(job_id, run) jobs.submit(job_id, run, requires_gpu=True)
return job_id return job_id
@router.post("") @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() 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: if upload_path:
src = Path(upload_path) src = Path(upload_path)
if not src.exists(): if not src.exists():
raise HTTPException(400, f"Uploaded file not found at {upload_path}") raise HTTPException(400, f"Uploaded file not found at {upload_path}")
safe_filename = os.path.basename(src) 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)) shutil.move(str(src), str(video_path))
elif file: elif file:
safe_filename = os.path.basename(file.filename or f"{session_id}") 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: with open(video_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer) shutil.copyfileobj(file.file, buffer)
else: else:
@@ -133,13 +152,13 @@ async def create_session(name: str = Form(...), file: UploadFile = File(None), u
with db.tx() as conn: with db.tx() as conn:
conn.execute( conn.execute(
"INSERT INTO sessions (id, name, original_filename, video_path, status, created_at) " "INSERT INTO sessions (id, name, original_filename, video_path, campaign_id, status, created_at) "
"VALUES (?, ?, ?, ?, 'uploaded', ?)", "VALUES (?, ?, ?, ?, ?, 'uploaded', ?)",
(session_id, name, safe_filename, str(video_path), db.now()), (session_id, name, safe_filename, str(video_path), campaign_id, db.now()),
) )
audio_path = config.AUDIO_DIR / f"{session_id}.wav" audio_path = audio_dir / f"{session_id}.wav"
transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json" transcript_path = transcript_dir / f"{session_id}.json"
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path) job_id = _run_transcription(session_id, video_path, audio_path, transcript_path)
return {"session_id": session_id, "job_id": job_id} return {"session_id": session_id, "job_id": job_id}
@@ -153,6 +172,11 @@ def retry_transcription(session_id: str):
raise HTTPException(404, "Session not found") raise HTTPException(404, "Session not found")
session = _row_to_dict(row) session = _row_to_dict(row)
video_path = Path(session["video_path"]) 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" audio_path = config.AUDIO_DIR / f"{session_id}.wav"
transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json" transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json"
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path) job_id = _run_transcription(session_id, video_path, audio_path, transcript_path)

View File

@@ -8,6 +8,8 @@ import Notes from './pages/Notes';
import Files from './pages/Files'; import Files from './pages/Files';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import Diagnostics from './pages/Diagnostics'; import Diagnostics from './pages/Diagnostics';
import Campaigns from './pages/Campaigns';
import Layout from './components/Layout';
import { api } from './api'; import { api } from './api';
function App() { function App() {
@@ -41,8 +43,9 @@ function App() {
{/* If configured, serve the main sessions workspace */} {/* If configured, serve the main sessions workspace */}
{isConfigured ? ( {isConfigured ? (
<> <Route element={<Layout />}>
<Route path="/" element={<Sessions />} /> <Route path="/" element={<Sessions />} />
<Route path="/campaigns" element={<Campaigns />} />
<Route path="/sessions/:id" element={<SessionDetail />} /> <Route path="/sessions/:id" element={<SessionDetail />} />
<Route path="/sessions/:id/speakers" element={<Speakers />} /> <Route path="/sessions/:id/speakers" element={<Speakers />} />
<Route path="/sessions/:id/notes" element={<Notes />} /> <Route path="/sessions/:id/notes" element={<Notes />} />
@@ -50,7 +53,7 @@ function App() {
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/diagnostics" element={<Diagnostics />} /> <Route path="/diagnostics" element={<Diagnostics />} />
<Route path="*" element={<Navigate to="/" />} /> <Route path="*" element={<Navigate to="/" />} />
</> </Route>
) : ( ) : (
<Route path="*" element={<Navigate to="/setup" />} /> <Route path="*" element={<Navigate to="/setup" />} />
)} )}

View File

@@ -36,6 +36,14 @@ export interface Job {
updated_at: number; updated_at: number;
} }
export interface Campaign {
id: string;
name: string;
description: string;
created_at: number;
session_count: number;
}
export type DeleteStrategy = 'all' | 'artifacts_only' | 'none'; export type DeleteStrategy = 'all' | 'artifacts_only' | 'none';
export const jobApi = { export const jobApi = {
@@ -55,6 +63,41 @@ export const jobApi = {
}, },
}; };
export const campaignApi = {
list: async (): Promise<Campaign[]> => {
const res = await fetch(`${BASE_URL}/campaigns`);
if (!res.ok) throw new Error('Failed to list campaigns');
return res.json();
},
create: async (name: string, description?: string): Promise<Campaign> => {
const res = await fetch(`${BASE_URL}/campaigns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
});
if (!res.ok) throw new Error('Failed to create campaign');
return res.json();
},
get: async (id: string): Promise<Campaign> => {
const res = await fetch(`${BASE_URL}/campaigns/${id}`);
if (!res.ok) throw new Error('Failed to get campaign');
return res.json();
},
update: async (id: string, body: Partial<Pick<Campaign, 'name' | 'description'>>): Promise<Campaign> => {
const res = await fetch(`${BASE_URL}/campaigns/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed to update campaign');
return res.json();
},
delete: async (id: string): Promise<void> => {
const res = await fetch(`${BASE_URL}/campaigns/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete campaign');
},
};
export const api = { export const api = {
// Settings // Settings
getSettings: async (): Promise<AppSettings> => { getSettings: async (): Promise<AppSettings> => {
@@ -75,17 +118,19 @@ export const api = {
}, },
// Sessions // Sessions
listSessions: async (): Promise<any[]> => { listSessions: async (campaignId?: string): Promise<any[]> => {
const res = await fetch(`${BASE_URL}/sessions`); const qs = campaignId ? `?campaign_id=${encodeURIComponent(campaignId)}` : '';
const res = await fetch(`${BASE_URL}/sessions${qs}`);
return res.json(); return res.json();
}, },
createSession: async (name: string, file: File, onProgress?: (pct: number) => void): Promise<{ session_id: string; job_id: string }> => { createSession: async (name: string, file: File, onProgress?: (pct: number) => void, campaignId?: string): Promise<{ session_id: string; job_id: string }> => {
const CHUNK_THRESHOLD = 90 * 1024 * 1024; const CHUNK_THRESHOLD = 90 * 1024 * 1024;
if (file.size > CHUNK_THRESHOLD) { if (file.size > CHUNK_THRESHOLD) {
const result = await api.uploadFileInChunks(file, onProgress || (() => {})); const result = await api.uploadFileInChunks(file, onProgress || (() => {}));
const formData = new FormData(); const formData = new FormData();
formData.append('name', name); formData.append('name', name);
formData.append('upload_path', result.filepath); formData.append('upload_path', result.filepath);
if (campaignId) formData.append('campaign_id', campaignId);
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData }); const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Failed to create session'); if (!res.ok) throw new Error('Failed to create session');
return res.json(); return res.json();
@@ -93,6 +138,7 @@ export const api = {
const formData = new FormData(); const formData = new FormData();
formData.append('name', name); formData.append('name', name);
formData.append('file', file); formData.append('file', file);
if (campaignId) formData.append('campaign_id', campaignId);
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData }); const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Failed to create session'); if (!res.ok) throw new Error('Failed to create session');
return res.json(); return res.json();
@@ -195,34 +241,37 @@ export const api = {
}, },
// Files // Files
browseFiles: async (path?: string): Promise<{ entries: any[]; current_path: string; parent_path: string | null }> => { browseFiles: async (path?: string, campaignId?: string): Promise<{ entries: any[]; current_path: string; parent_path: string | null }> => {
const qs = path ? `?path=${encodeURIComponent(path)}` : ''; const params = new URLSearchParams();
const res = await fetch(`${BASE_URL}/files/browse${qs}`); if (path) params.set('path', path);
if (campaignId) params.set('campaign_id', campaignId);
const qs = params.toString();
const res = await fetch(`${BASE_URL}/files/browse${qs ? `?${qs}` : ''}`);
if (!res.ok) throw new Error('Failed to browse files'); if (!res.ok) throw new Error('Failed to browse files');
return res.json(); return res.json();
}, },
deleteFiles: async (paths: string[]): Promise<{ deleted: string[]; errors: any[] }> => { deleteFiles: async (paths: string[], campaignId?: string): Promise<{ deleted: string[]; errors: any[] }> => {
const res = await fetch(`${BASE_URL}/files`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths }) }); const res = await fetch(`${BASE_URL}/files`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths, campaign_id: campaignId }) });
if (!res.ok) throw new Error('Failed to delete files'); if (!res.ok) throw new Error('Failed to delete files');
return res.json(); return res.json();
}, },
uploadFile: async (file: File, dir?: string): Promise<{ path: string }> => { uploadFile: async (file: File, dir?: string, campaignId?: string): Promise<{ path: string }> => {
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
if (dir) form.append('dir', dir); if (dir) form.append('dir', dir);
if (campaignId) form.append('campaign_id', campaignId);
const res = await fetch(`${BASE_URL}/files/upload`, { method: 'POST', body: form }); const res = await fetch(`${BASE_URL}/files/upload`, { method: 'POST', body: form });
if (!res.ok) throw new Error('Failed to upload file'); if (!res.ok) throw new Error('Failed to upload file');
return res.json(); return res.json();
}, },
copyFile: async (source: string, dest_dir?: string): Promise<{ source: string; dest: string }> => { downloadFileUrl: (path: string, campaignId?: string): string => {
const res = await fetch(`${BASE_URL}/files/copy`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, dest_dir }) }); const params = new URLSearchParams({ path });
if (!res.ok) throw new Error('Failed to copy file'); if (campaignId) params.set('campaign_id', campaignId);
return res.json(); return `${BASE_URL}/files/download?${params.toString()}`;
}, },
downloadFileUrl: (path: string): string => { viewFileUrl: (path: string, campaignId?: string): string => {
return `${BASE_URL}/files/download?path=${encodeURIComponent(path)}`; const params = new URLSearchParams({ path });
}, if (campaignId) params.set('campaign_id', campaignId);
viewFileUrl: (path: string): string => { return `${BASE_URL}/files/view?${params.toString()}`;
return `${BASE_URL}/files/view?path=${encodeURIComponent(path)}`;
}, },
}; };

View File

@@ -0,0 +1,16 @@
import { Outlet } from "react-router-dom";
import Sidebar from "./Sidebar";
import { CampaignProvider } from "../contexts/CampaignContext";
export default function Layout() {
return (
<CampaignProvider>
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 ml-60">
<Outlet />
</main>
</div>
</CampaignProvider>
);
}

View File

@@ -0,0 +1,95 @@
import { useState, useRef, useEffect } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useCampaign } from "../contexts/CampaignContext";
const NAV = [
{ to: "/", label: "Transcriptions" },
{ to: "/files", label: "File Browser" },
{ to: "/settings", label: "Settings" },
{ to: "/diagnostics", label: "System Check" },
];
export default function Sidebar() {
const { pathname } = useLocation();
const { currentCampaign, setCampaign, campaigns } = useCampaign();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const nav = useNavigate();
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
return (
<aside className="fixed left-0 top-0 h-screen w-60 bg-panel border-r border-white/5 flex flex-col z-40">
<div className="px-6 pt-8 pb-5 border-b border-white/5">
<h1 className="font-display text-xl text-brass tracking-tight">Nat20 Notes</h1>
</div>
{/* Campaign selector */}
<div className="px-3 pt-4 pb-3 relative" ref={ref}>
<button
className="w-full flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-white/5 hover:bg-white/10 transition-colors text-left"
onClick={() => setOpen(!open)}
>
<span className="text-sm font-medium truncate">
{currentCampaign?.name ?? "No campaign"}
</span>
<span className="text-ink/40 text-xs">{open ? "\u25B2" : "\u25BC"}</span>
</button>
{open && (
<div className="absolute left-3 right-3 top-full mt-1 rounded-md bg-panel2 border border-white/10 shadow-xl z-50 overflow-hidden">
{campaigns.map((c) => (
<button
key={c.id}
className={`w-full text-left px-3 py-2 text-sm hover:bg-white/5 transition-colors flex items-center justify-between ${
c.id === currentCampaign?.id ? "text-brass" : "text-ink/70"
}`}
onClick={() => {
setCampaign(c);
setOpen(false);
}}
>
<span className="truncate">{c.name}</span>
{c.id === currentCampaign?.id && <span className="text-brass text-xs">\u2713</span>}
</button>
))}
<div className="border-t border-white/10">
<button
className="w-full text-left px-3 py-2 text-xs text-ink/50 hover:text-ink hover:bg-white/5 transition-colors"
onClick={() => { nav("/campaigns"); setOpen(false); }}
>
Manage Campaigns
</button>
</div>
</div>
)}
</div>
<nav className="flex-1 flex flex-col gap-1 px-3 pt-3">
{NAV.map(({ to, label }) => {
const active = pathname === to;
return (
<Link
key={to}
to={to}
className={`flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors ${
active
? "bg-brass/10 text-brass"
: "text-ink/50 hover:text-ink hover:bg-white/5"
}`}
>
{label}
</Link>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,59 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { campaignApi, type Campaign } from '../api';
interface CampaignContextType {
currentCampaign: Campaign | null;
setCampaign: (c: Campaign | null) => void;
campaigns: Campaign[];
refreshCampaigns: () => void;
}
const CampaignContext = createContext<CampaignContextType | null>(null);
export function CampaignProvider({ children }: { children: ReactNode }) {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [currentCampaign, setCurrentCampaign] = useState<Campaign | null>(null);
const refreshCampaigns = useCallback(async () => {
try {
const list = await campaignApi.list();
setCampaigns(list);
} catch {}
}, []);
useEffect(() => {
refreshCampaigns();
}, [refreshCampaigns]);
useEffect(() => {
if (campaigns.length === 0) return;
if (!currentCampaign) {
const stored = localStorage.getItem('campaignId');
const match = stored ? campaigns.find(c => c.id === stored) : null;
setCurrentCampaign(match || campaigns[0]);
} else {
const stillExists = campaigns.find(c => c.id === currentCampaign.id);
if (!stillExists) {
setCurrentCampaign(campaigns[0] || null);
}
}
}, [campaigns]);
const setCampaign = (c: Campaign | null) => {
if (c) localStorage.setItem('campaignId', c.id);
else localStorage.removeItem('campaignId');
setCurrentCampaign(c);
};
return (
<CampaignContext.Provider value={{ currentCampaign, setCampaign, campaigns, refreshCampaigns }}>
{children}
</CampaignContext.Provider>
);
}
export function useCampaign() {
const ctx = useContext(CampaignContext);
if (!ctx) throw new Error('useCampaign must be used within CampaignProvider');
return ctx;
}

View File

@@ -0,0 +1,120 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { campaignApi, type Campaign } from '../api';
import { useCampaign } from '../contexts/CampaignContext';
export default function Campaigns() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [name, setName] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const nav = useNavigate();
const { setCampaign, refreshCampaigns } = useCampaign();
const load = async () => {
const list = await campaignApi.list();
setCampaigns(list);
};
useEffect(() => { load(); }, []);
const create = async () => {
if (!name.trim()) return;
const c = await campaignApi.create(name.trim());
setName('');
await load();
await refreshCampaigns();
setCampaign(c);
nav('/');
};
const startEdit = (c: Campaign) => {
setEditingId(c.id);
setEditName(c.name);
};
const saveEdit = async (id: string) => {
if (!editName.trim()) return;
await campaignApi.update(id, { name: editName.trim() });
setEditingId(null);
await load();
await refreshCampaigns();
};
const deleteCampaign = async (id: string) => {
if (!confirm('Delete this campaign? Sessions will be unlinked (notes/history preserved).')) return;
await campaignApi.delete(id);
await load();
await refreshCampaigns();
};
const select = (c: Campaign) => {
setCampaign(c);
nav('/');
};
return (
<div className="max-w-3xl mx-auto py-16 px-4">
<div className="eyebrow mb-2">Nat20 Notes</div>
<h1 className="font-display text-4xl mb-8">Campaigns</h1>
<div className="card p-6 mb-8">
<h2 className="font-display text-xl mb-4">New campaign</h2>
<div className="flex gap-3">
<input
className="input flex-1"
placeholder="Campaign name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && create()}
/>
<button className="btn-primary" disabled={!name.trim()} onClick={create}>
Create
</button>
</div>
</div>
<div className="space-y-3">
{campaigns.length === 0 && (
<p className="text-ink/40">No campaigns yet create one above.</p>
)}
{campaigns.map((c) => (
<div key={c.id} className="card p-4 flex items-center justify-between hover:border-brass/40 border border-transparent">
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => select(c)}>
{editingId === c.id ? (
<input
className="input w-full"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit(c.id);
if (e.key === 'Escape') setEditingId(null);
}}
onBlur={() => saveEdit(c.id)}
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<>
<div className="font-medium">{c.name}</div>
<div className="text-xs text-ink/40 mt-0.5">
{c.session_count} session{c.session_count !== 1 ? 's' : ''}
{c.description && ` \u00B7 ${c.description}`}
</div>
</>
)}
</div>
<div className="flex gap-2 ml-4 shrink-0">
<button className="btn-secondary text-xs px-2 py-1" onClick={(e) => { e.stopPropagation(); startEdit(c); }}>
Rename
</button>
<button className="btn-secondary text-xs px-2 py-1 text-rose-400" onClick={(e) => { e.stopPropagation(); deleteCampaign(c.id); }}>
Delete
</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
@@ -27,7 +26,6 @@ export default function Diagnostics() {
return ( return (
<div className="max-w-xl mx-auto py-16 px-4"> <div className="max-w-xl mx-auto py-16 px-4">
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-2">System check</h1> <h1 className="font-display text-3xl mt-4 mb-2">System check</h1>
<p className="text-ink/60 mb-6">Run this before your first session, or any time something isn't working.</p> <p className="text-ink/60 mb-6">Run this before your first session, or any time something isn't working.</p>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import { useCampaign } from "../contexts/CampaignContext";
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -47,11 +48,11 @@ function isText(path: string) {
} }
export default function Files() { export default function Files() {
const { currentCampaign } = useCampaign();
const [entries, setEntries] = useState<any[]>([]); const [entries, setEntries] = useState<any[]>([]);
const [currentPath, setCurrentPath] = useState(""); const [currentPath, setCurrentPath] = useState("");
const [parentPath, setParentPath] = useState<string | null>(null); const [parentPath, setParentPath] = useState<string | null>(null);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set()); const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [copiedFile, setCopiedFile] = useState<{ path: string; name: string } | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -59,20 +60,22 @@ export default function Files() {
const [expandedContent, setExpandedContent] = useState<string | null>(null); const [expandedContent, setExpandedContent] = useState<string | null>(null);
const [expanding, setExpanding] = useState(false); const [expanding, setExpanding] = useState(false);
const campaignId = currentCampaign?.id;
const load = useCallback(async (path: string) => { const load = useCallback(async (path: string) => {
setLoading(true); setLoading(true);
setExpandedPath(null); setExpandedPath(null);
setExpandedContent(null); setExpandedContent(null);
try { try {
const res = await api.browseFiles(path || undefined); const res = await api.browseFiles(path || undefined, campaignId);
setEntries(res.entries); setEntries(res.entries);
setCurrentPath(res.current_path); setCurrentPath(res.current_path);
setParentPath(res.parent_path); setParentPath(res.parent_path);
} catch { setEntries([]); } } catch { setEntries([]); }
setLoading(false); setLoading(false);
}, []); }, [campaignId]);
useEffect(() => { load(currentPath); }, []); useEffect(() => { load(currentPath); }, [campaignId]);
const navigate = (path: string) => { const navigate = (path: string) => {
setSelectedPaths(new Set()); setSelectedPaths(new Set());
@@ -84,23 +87,11 @@ export default function Files() {
setTimeout(() => setToast(null), 2000); setTimeout(() => setToast(null), 2000);
}; };
// Keyboard shortcuts // Keyboard shortcuts (delete only)
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName; const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return; if (tag === "INPUT" || tag === "TEXTAREA") return;
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
e.preventDefault();
if (selectedPaths.size > 0) {
const first = entries.find(f => selectedPaths.has(f.path));
if (first) { setCopiedFile({ path: first.path, name: first.name }); showToast(`Copied: ${first.name}`); }
}
}
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.preventDefault();
if (copiedFile) handlePaste();
}
if (e.key === "Delete" && selectedPaths.size > 0) handleDelete(); if (e.key === "Delete" && selectedPaths.size > 0) handleDelete();
if ((e.ctrlKey || e.metaKey) && e.key === "a") { if ((e.ctrlKey || e.metaKey) && e.key === "a") {
e.preventDefault(); e.preventDefault();
@@ -109,7 +100,7 @@ export default function Files() {
}; };
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [selectedPaths, copiedFile, entries]); }, [selectedPaths, entries]);
const toggleSelect = (path: string) => { const toggleSelect = (path: string) => {
setSelectedPaths(prev => { setSelectedPaths(prev => {
@@ -125,7 +116,7 @@ export default function Files() {
if (paths.length === 0) return; if (paths.length === 0) return;
if (!confirm(`Delete ${paths.length} item(s)?`)) return; if (!confirm(`Delete ${paths.length} item(s)?`)) return;
try { try {
const res = await api.deleteFiles(paths); const res = await api.deleteFiles(paths, campaignId);
setSelectedPaths(new Set()); setSelectedPaths(new Set());
setExpandedPath(null); setExpandedPath(null);
setExpandedContent(null); setExpandedContent(null);
@@ -136,18 +127,6 @@ export default function Files() {
} }
}; };
const handlePaste = async () => {
if (!copiedFile) return;
try {
const res = await api.copyFile(copiedFile.path, currentPath || undefined);
setCopiedFile(null);
showToast(`Pasted as: ${res.dest.split("/").pop()}`);
load(currentPath);
} catch (e: any) {
showToast(e.message || "Paste failed");
}
};
const handleUpload = async () => { const handleUpload = async () => {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
@@ -156,7 +135,7 @@ export default function Files() {
if (!file) return; if (!file) return;
setUploading(true); setUploading(true);
try { try {
await api.uploadFile(file, currentPath || undefined); await api.uploadFile(file, currentPath || undefined, campaignId);
showToast("Uploaded"); showToast("Uploaded");
load(currentPath); load(currentPath);
} catch (e: any) { } catch (e: any) {
@@ -179,7 +158,7 @@ export default function Files() {
} else if (isText(entry.path)) { } else if (isText(entry.path)) {
setExpanding(true); setExpanding(true);
try { try {
const res = await fetch(api.viewFileUrl(entry.path)); const res = await fetch(api.viewFileUrl(entry.path, campaignId));
const text = await res.text(); const text = await res.text();
setExpandedContent(text); setExpandedContent(text);
} catch { } catch {
@@ -200,7 +179,6 @@ export default function Files() {
{toast} {toast}
</div> </div>
)} )}
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-2">File Browser</h1> <h1 className="font-display text-3xl mt-4 mb-2">File Browser</h1>
<Breadcrumb path={currentPath} onNavigate={navigate} /> <Breadcrumb path={currentPath} onNavigate={navigate} />
@@ -209,30 +187,18 @@ export default function Files() {
<div className="card p-4 mb-6"> <div className="card p-4 mb-6">
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex gap-2"> <div className="flex gap-2">
<button className="btn-secondary text-sm" onClick={() => navigate(parentPath || "")} disabled={!parentPath}> <button className="btn-secondary text-sm" onClick={() => navigate(parentPath || "")} disabled={parentPath === null}>
&uarr; Up &uarr; Up
</button> </button>
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{copiedFile && (
<span className="text-xs text-brass font-mono truncate max-w-[200px]" title={copiedFile.path}>
Clipboard: {copiedFile.name}
</span>
)}
<button className="btn-secondary text-sm" onClick={handleUpload} disabled={uploading}> <button className="btn-secondary text-sm" onClick={handleUpload} disabled={uploading}>
{uploading ? "Uploading..." : "Upload"} {uploading ? "Uploading..." : "Upload"}
</button> </button>
<button className="btn-secondary text-sm" onClick={() => {
if (selectedPaths.size > 0) {
const first = entries.find(f => selectedPaths.has(f.path));
if (first) { setCopiedFile({ path: first.path, name: first.name }); showToast(`Copied: ${first.name}`); }
}
}} disabled={selectedCount === 0}>Copy</button>
<button className="btn-secondary text-sm" onClick={handlePaste} disabled={!copiedFile}>Paste</button>
<button className="btn-secondary text-sm" onClick={handleDelete} disabled={selectedCount === 0}>Delete</button> <button className="btn-secondary text-sm" onClick={handleDelete} disabled={selectedCount === 0}>Delete</button>
<button className="btn-secondary text-sm" onClick={() => { <button className="btn-secondary text-sm" onClick={() => {
const first = entries.find(f => selectedPaths.has(f.path)); const first = entries.find(f => selectedPaths.has(f.path));
if (first) window.open(api.downloadFileUrl(first.path), "_blank"); if (first) window.open(api.downloadFileUrl(first.path, campaignId), "_blank");
}} disabled={selectedCount !== 1}>Download</button> }} disabled={selectedCount !== 1}>Download</button>
</div> </div>
</div> </div>
@@ -311,7 +277,7 @@ export default function Files() {
<td colSpan={6} className="p-0"> <td colSpan={6} className="p-0">
<div className="bg-black/20 px-4 py-4"> <div className="bg-black/20 px-4 py-4">
{isAudio(e.name) ? ( {isAudio(e.name) ? (
<audio controls className="w-full max-w-xl" src={api.viewFileUrl(e.path)} autoPlay> <audio controls className="w-full max-w-xl" src={api.viewFileUrl(e.path, campaignId)}>
Your browser does not support audio playback. Your browser does not support audio playback.
</audio> </audio>
) : expanding ? ( ) : expanding ? (

View File

@@ -191,18 +191,21 @@ export default function SessionDetail() {
return () => link.remove(); return () => link.remove();
}, [session?.status, id]); }, [session?.status, id]);
// Auto-redirect to speakers page once transcription completes // Auto-redirect to speakers page once transcription completes.
// Skip if there's an active summarize job (user just came from the speakers
// page after starting note generation).
const autoRedirected = useRef(false); const autoRedirected = useRef(false);
useEffect(() => { useEffect(() => {
if (session?.status === 'transcribed') { if (session?.status === 'transcribed') {
if (!autoRedirected.current) { const hasActiveSummarize = job?.job_type === 'summarize' && ['queued', 'running'].includes(job.status);
if (!hasActiveSummarize && !autoRedirected.current) {
autoRedirected.current = true; autoRedirected.current = true;
nav(`/sessions/${id}/speakers`); nav(`/sessions/${id}/speakers`);
} }
} else { } else {
autoRedirected.current = false; autoRedirected.current = false;
} }
}, [session?.status, id, nav]); }, [session?.status, id, nav, job]);
const retryTranscription = async () => { const retryTranscription = async () => {
if (!id) return; if (!id) return;
@@ -226,13 +229,17 @@ export default function SessionDetail() {
if (!session) return null; if (!session) return null;
const statusText = job && ['queued', 'running'].includes(job.status)
? job.progress || 'Waiting...'
: (STATUS_LABEL[session.status] ?? session.status);
return ( return (
<div className="max-w-2xl mx-auto py-16 px-4"> <div className="max-w-2xl mx-auto py-16 px-4">
{toast && <Toast message={toast} onDone={() => setToast(null)} />} {toast && <Toast message={toast} onDone={() => setToast(null)} />}
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link> <Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-1">{session.name}</h1> <h1 className="font-display text-3xl mt-4 mb-1">{session.name}</h1>
<p className="text-ink/40 font-mono text-xs mb-6">{session.original_filename}</p> <p className="text-ink/40 font-mono text-xs mb-6">{session.original_filename}</p>
<p className="text-brass mb-8">{STATUS_LABEL[session.status] ?? session.status}</p> <p className="text-brass mb-8">{statusText}</p>
{job && ( {job && (
<div className="mb-8"> <div className="mb-8">

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import { useCampaign } from "../contexts/CampaignContext";
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
uploaded: "Ready to transcribe", uploaded: "Ready to transcribe",
@@ -10,13 +11,16 @@ const STATUS_LABEL: Record<string, string> = {
}; };
export default function Sessions() { export default function Sessions() {
const { currentCampaign } = useCampaign();
const [sessions, setSessions] = useState<any[]>([]); const [sessions, setSessions] = useState<any[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState<number | null>(null); const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const nav = useNavigate(); const nav = useNavigate();
const refresh = useCallback(() => api.listSessions().then(setSessions), []); const refresh = useCallback(() => {
return api.listSessions(currentCampaign?.id).then(setSessions);
}, [currentCampaign?.id]);
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, [refresh]); }, [refresh]);
@@ -32,15 +36,19 @@ export default function Sessions() {
const upload = async () => { const upload = async () => {
if (!file || !name) return; if (!file || !name) return;
setUploadProgress(0); setUploadProgress(0);
const { session_id } = await api.createSession(name, file, setUploadProgress); const { session_id } = await api.createSession(name, file, setUploadProgress, currentCampaign?.id);
setUploadProgress(null); setUploadProgress(null);
nav(`/sessions/${session_id}`); nav(`/sessions/${session_id}`);
}; };
return ( return (
<div className="max-w-3xl mx-auto py-16 px-4"> <div className="max-w-3xl mx-auto py-16 px-4">
<div className="eyebrow mb-2">Nat20 Notes</div> <div className="eyebrow mb-2">
<h1 className="font-display text-4xl mb-8">Your Transcriptions</h1> {currentCampaign?.name ?? "Nat20 Notes"}
</div>
<h1 className="font-display text-4xl mb-8">
{currentCampaign ? `${currentCampaign.name} \u2014 Sessions` : "Sessions"}
</h1>
<div className="card p-6 mb-8"> <div className="card p-6 mb-8">
<h2 className="font-display text-xl mb-4">New transcription</h2> <h2 className="font-display text-xl mb-4">New transcription</h2>
@@ -86,11 +94,6 @@ export default function Sessions() {
})} })}
</div> </div>
<div className="mt-10 flex gap-4">
<Link to="/files" className="text-sm text-ink/40 hover:text-brass">Files</Link>
<Link to="/diagnostics" className="text-sm text-ink/40 hover:text-brass">System check</Link>
<Link to="/settings" className="text-sm text-ink/40 hover:text-brass">Settings</Link>
</div>
</div> </div>
); );
} }

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
export default function Settings() { export default function Settings() {
@@ -22,7 +21,6 @@ export default function Settings() {
return ( return (
<div className="max-w-4xl mx-auto py-16 px-4"> <div className="max-w-4xl mx-auto py-16 px-4">
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-8">Settings</h1> <h1 className="font-display text-3xl mt-4 mb-8">Settings</h1>
<div className="grid grid-cols-2 gap-6 mb-6"> <div className="grid grid-cols-2 gap-6 mb-6">