Add files page and router, update settings, sessions, and notes
This commit is contained in:
@@ -63,9 +63,15 @@ def init_db():
|
||||
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
dm_notes TEXT,
|
||||
player_recap TEXT,
|
||||
player_recap_prompt TEXT,
|
||||
generated_at REAL
|
||||
)
|
||||
""")
|
||||
# migrate older databases that lack the prompt column
|
||||
try:
|
||||
conn.execute("ALTER TABLE notes ADD COLUMN player_recap_prompt TEXT")
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -13,6 +13,7 @@ from .routers import notes as notes_router
|
||||
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
|
||||
|
||||
setup_logging()
|
||||
log = get_logger(__name__)
|
||||
@@ -52,6 +53,7 @@ app.include_router(notes_router.router)
|
||||
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.get("/api/sessions/{session_id}/audio")
|
||||
|
||||
224
backend/app/routers/files.py
Normal file
224
backend/app/routers/files.py
Normal file
@@ -0,0 +1,224 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from .. import database as db, config
|
||||
|
||||
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)):
|
||||
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():
|
||||
return p
|
||||
stem = Path(name).stem
|
||||
suffix = Path(name).suffix
|
||||
n = 2
|
||||
while (parent / f"{stem}_copy_{n}{suffix}").exists():
|
||||
n += 1
|
||||
return parent / f"{stem}_copy_{n}{suffix}"
|
||||
|
||||
|
||||
def _enrich(entries, conn):
|
||||
for e in entries:
|
||||
if e["type"] == "file":
|
||||
sid = e["name"].split("_")[0]
|
||||
e["session_id"] = sid
|
||||
row = conn.execute("SELECT name, status FROM sessions WHERE id = ?", (sid,)).fetchone()
|
||||
e["session_name"] = row["name"] if row else "(orphan)"
|
||||
e["session_status"] = row["status"] if row else "unknown"
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
def browse(path: str = ""):
|
||||
clean = path.strip("/")
|
||||
entries = []
|
||||
conn = db.get_conn()
|
||||
|
||||
# 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()
|
||||
for r in rows:
|
||||
for kind, text in (("player_recap", r["player_recap"]), ("dm_notes", r["dm_notes"])):
|
||||
if not text:
|
||||
continue
|
||||
entries.append({
|
||||
"name": f"{r['session_id']}_{kind}.txt",
|
||||
"type": "file",
|
||||
"path": f"notes/{r['session_id']}_{kind}.txt",
|
||||
"notes_kind": kind,
|
||||
"size": len(text),
|
||||
"modified_at": int(r["generated_at"] or 0),
|
||||
"preview": text[:120],
|
||||
})
|
||||
_enrich(entries, conn)
|
||||
return {"entries": entries, "current_path": "notes", "parent_path": ""}
|
||||
|
||||
# Real directory on disk
|
||||
resolved = DATA / clean
|
||||
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))
|
||||
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
|
||||
entry["modified_at"] = int(f.stat().st_mtime)
|
||||
entries.append(entry)
|
||||
|
||||
_enrich([e for e in entries if e["type"] == "file"], conn)
|
||||
|
||||
parts = clean.split("/")
|
||||
parent = "/".join(parts[:-1]) if len(parts) > 1 else ""
|
||||
return {"entries": entries, "current_path": clean, "parent_path": parent or None}
|
||||
|
||||
|
||||
_MIME_MAP: dict[str, str] = {
|
||||
".wav": "audio/wav",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".m4a": "audio/mp4",
|
||||
".ogg": "audio/ogg",
|
||||
".flac": "audio/flac",
|
||||
".json": "application/json",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/view")
|
||||
def view_file(path: str):
|
||||
# Virtual notes file — serve from DB
|
||||
if path.startswith("notes/"):
|
||||
parts = Path(path).stem.split("_")
|
||||
session_id = parts[0]
|
||||
kind = parts[1] if len(parts) > 1 else None
|
||||
conn = db.get_conn()
|
||||
row = conn.execute("SELECT dm_notes, player_recap FROM notes WHERE session_id = ?", (session_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Notes not found")
|
||||
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)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
mime = _MIME_MAP.get(file_path.suffix.lower(), "application/octet-stream")
|
||||
return FileResponse(file_path, media_type=mime, headers={"Content-Disposition": "inline"})
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
def download_file(path: str):
|
||||
file_path = _safe_path(path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
return FileResponse(file_path, filename=file_path.name)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(request: Request):
|
||||
form = await request.form()
|
||||
file_field = form.get("file")
|
||||
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)
|
||||
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))}
|
||||
|
||||
|
||||
@router.delete("")
|
||||
def delete_files(body: dict):
|
||||
paths = body.get("paths", [])
|
||||
deleted = []
|
||||
errors = []
|
||||
for p in paths:
|
||||
try:
|
||||
fp = _safe_path(p)
|
||||
if fp.exists():
|
||||
fp.unlink()
|
||||
deleted.append(p)
|
||||
elif p.startswith("notes/"):
|
||||
parts = Path(p).stem.split("_")
|
||||
session_id = parts[0]
|
||||
conn = db.get_conn()
|
||||
kind = parts[1] if len(parts) > 1 else None
|
||||
if kind == "dm_notes":
|
||||
conn.execute("UPDATE notes SET dm_notes = NULL WHERE session_id = ?", (session_id,))
|
||||
elif kind == "player_recap":
|
||||
conn.execute("UPDATE notes SET player_recap = NULL WHERE session_id = ?", (session_id,))
|
||||
else:
|
||||
conn.execute("DELETE FROM notes WHERE session_id = ?", (session_id,))
|
||||
conn.commit()
|
||||
deleted.append(p)
|
||||
else:
|
||||
errors.append({"path": p, "error": "not found"})
|
||||
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)),
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from .. import database as db, jobs
|
||||
from ..pipeline.turns import load_turns
|
||||
from ..pipeline.summarize import summarize_session
|
||||
from ..pipeline.summarize import summarize_session, PLAYER_FINAL_PROMPTS
|
||||
|
||||
router = APIRouter(prefix="/api/sessions/{session_id}/notes", tags=["notes"])
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
def generate_notes(session_id: str):
|
||||
async def generate_notes(session_id: str, request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
session = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
if not session:
|
||||
raise HTTPException(404, "Session not found")
|
||||
@@ -22,21 +23,31 @@ def generate_notes(session_id: str):
|
||||
db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,))
|
||||
if r["display_name"]}
|
||||
|
||||
# Per-request style overrides the global setting
|
||||
style = body.get("player_recap_style") or settings.get("player_recap_style") or "story"
|
||||
custom = body.get("player_recap_custom_prompt") or settings.get("player_recap_custom_prompt") or ""
|
||||
|
||||
# Resolve the prompt template (same logic as summarize_session)
|
||||
if style == "custom":
|
||||
player_template = custom if custom else PLAYER_FINAL_PROMPTS["story"]
|
||||
else:
|
||||
player_template = PLAYER_FINAL_PROMPTS.get(style, PLAYER_FINAL_PROMPTS["story"])
|
||||
|
||||
job_id = db.create_job(session_id, "summarize")
|
||||
|
||||
def run(progress_cb):
|
||||
turns = load_turns(Path(session["transcript_path"]), speaker_map)
|
||||
dm_notes, player_recap = summarize_session(
|
||||
turns, settings, progress_cb=progress_cb,
|
||||
player_recap_style=settings.get("player_recap_style"),
|
||||
player_recap_custom_prompt=settings.get("player_recap_custom_prompt"),
|
||||
player_recap_style=style,
|
||||
player_recap_custom_prompt=custom,
|
||||
)
|
||||
with db.tx() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO notes (session_id, dm_notes, player_recap, generated_at) VALUES (?, ?, ?, ?) "
|
||||
"INSERT INTO notes (session_id, dm_notes, player_recap, player_recap_prompt, generated_at) VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(session_id) DO UPDATE SET dm_notes=excluded.dm_notes, "
|
||||
"player_recap=excluded.player_recap, generated_at=excluded.generated_at",
|
||||
(session_id, dm_notes, player_recap, db.now()),
|
||||
"player_recap=excluded.player_recap, player_recap_prompt=excluded.player_recap_prompt, generated_at=excluded.generated_at",
|
||||
(session_id, dm_notes, player_recap, player_template, db.now()),
|
||||
)
|
||||
conn.execute("UPDATE sessions SET status = 'complete' WHERE id = ?", (session_id,))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user