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

@@ -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)),
}