"""Saved records management — list, play, pause, resume, stop, rename, delete. Manages WAV recordings saved via the typed replay engine. """ from __future__ import annotations import json import threading from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse from pydantic import BaseModel from Project.Sanad.config import AUDIO_RECORDINGS_DIR from Project.Sanad.dashboard.routes._safe_io import ( safe_filename, safe_path_under, atomic_write_json, ) router = APIRouter() RECORDS_INDEX = AUDIO_RECORDINGS_DIR / "records.json" _INDEX_LOCK = threading.Lock() def _load_index() -> dict[str, Any]: if not RECORDS_INDEX.exists(): return {"records": [], "total_records": 0, "last_updated": ""} try: with open(RECORDS_INDEX, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError): # Backup corrupt file rather than nuking it try: RECORDS_INDEX.rename(RECORDS_INDEX.with_suffix(".json.corrupt")) except OSError: pass return {"records": [], "total_records": 0, "last_updated": ""} def _save_index(payload: dict[str, Any]): AUDIO_RECORDINGS_DIR.mkdir(parents=True, exist_ok=True) payload["total_records"] = len(payload.get("records", [])) atomic_write_json(RECORDS_INDEX, payload) def _resolve_path(path_str: str) -> Path: """Resolve record path — basename / relative / absolute. Legacy records stored absolute paths. New records store basenames. Both flavors resolve to a real file under AUDIO_RECORDINGS_DIR. """ if not path_str: return AUDIO_RECORDINGS_DIR p = Path(path_str) if p.is_absolute(): return p return AUDIO_RECORDINGS_DIR / p def _reconcile(payload: dict[str, Any]) -> tuple[dict[str, Any], int]: kept, removed = [], 0 for entry in payload.get("records", []): try: sp = _resolve_path(entry["files"]["speaker_recording"]["path"]) rp = _resolve_path(entry["files"]["gemini_raw_output"]["path"]) if sp.exists() and rp.exists(): kept.append(entry) else: removed += 1 except (KeyError, TypeError): removed += 1 payload["records"] = kept payload["total_records"] = len(kept) return payload, removed @router.get("/") async def list_records(): with _INDEX_LOCK: payload = _load_index() payload, removed = _reconcile(payload) if removed: _save_index(payload) return payload @router.get("/audio/{record_name}") async def stream_record_audio(record_name: str, kind: str = "speaker"): """Stream a record's WAV to the browser. Lite plays audio client-side (Audio element in index.html) instead of through the robot speaker. """ with _INDEX_LOCK: index = _load_index() entry = next((r for r in index.get("records", []) if r.get("record_name") == record_name), None) if entry is None: raise HTTPException(404, f"Record not found: {record_name}") file_key = "speaker_recording" if kind == "speaker" else "gemini_raw_output" try: raw_path = _resolve_path(entry["files"][file_key]["path"]).resolve() except KeyError: raise HTTPException(404, f"No '{kind}' file for record {record_name}") base = AUDIO_RECORDINGS_DIR.resolve() try: raw_path.relative_to(base) except ValueError: raise HTTPException(400, "Record path outside recordings directory.") if not raw_path.exists(): raise HTTPException(404, f"File not found: {raw_path.name}") return FileResponse(raw_path, media_type="audio/wav", filename=raw_path.name) class RecordRename(BaseModel): record_name: str new_name: str @router.post("/rename") async def rename_record(payload: RecordRename): new_name = safe_filename(payload.new_name) # Strip any extension the user provided — we add our own if new_name.lower().endswith(".wav"): new_name = new_name[:-4] if not new_name or new_name.startswith("."): raise HTTPException(400, "Invalid new name.") with _INDEX_LOCK: index = _load_index() entry = next( (r for r in index.get("records", []) if r.get("record_name") == payload.record_name), None, ) if entry is None: raise HTTPException(404, f"Record not found: {payload.record_name}") base = AUDIO_RECORDINGS_DIR.resolve() for key in ("speaker_recording", "gemini_raw_output"): try: old_path = _resolve_path(entry["files"][key]["path"]).resolve() old_path.relative_to(base) # ensure inside recordings dir except (KeyError, ValueError): continue if not old_path.exists(): continue suffix = "_raw.wav" if key == "gemini_raw_output" else ".wav" new_path = safe_path_under(AUDIO_RECORDINGS_DIR, f"{new_name}{suffix}") if new_path.exists(): raise HTTPException(409, f"File already exists: {new_path.name}") old_path.rename(new_path) entry["files"][key]["path"] = new_path.name # basename — portable entry["files"][key]["name"] = new_path.name entry["record_name"] = new_name _save_index(index) return {"ok": True, "record": entry} class RecordDelete(BaseModel): record_name: str @router.post("/delete") async def delete_record(payload: RecordDelete): with _INDEX_LOCK: index = _load_index() kept = [] deleted_entry = None for r in index.get("records", []): if r.get("record_name") == payload.record_name and deleted_entry is None: deleted_entry = r else: kept.append(r) if deleted_entry is None: raise HTTPException(404, f"Record not found: {payload.record_name}") base = AUDIO_RECORDINGS_DIR.resolve() deleted_files = [] for fi in deleted_entry.get("files", {}).values(): try: p = Path(fi.get("path", "")).resolve() p.relative_to(base) # never delete files outside recordings dir except (ValueError, OSError): continue if p.exists(): p.unlink() deleted_files.append(str(p)) index["records"] = kept _save_index(index) return {"ok": True, "deleted": payload.record_name, "deleted_files": deleted_files} @router.post("/delete-all") async def delete_all_records(): """Wipe every WAV under AUDIO_RECORDINGS_DIR and reset the index. Stays within AUDIO_RECORDINGS_DIR — never traverses elsewhere. """ base = AUDIO_RECORDINGS_DIR.resolve() deleted_files: list[str] = [] skipped: list[str] = [] with _INDEX_LOCK: if base.exists(): for wav in base.glob("*.wav"): try: p = wav.resolve() p.relative_to(base) # double-check it lives under base except (ValueError, OSError): skipped.append(str(wav)) continue try: p.unlink() deleted_files.append(p.name) except OSError as exc: skipped.append(f"{wav.name}: {exc}") _save_index({"records": [], "total_records": 0, "last_updated": ""}) return { "ok": True, "deleted_count": len(deleted_files), "deleted_files": deleted_files, "skipped": skipped, }