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