clio/clio-api/main.py

417 lines
12 KiB
Python
Raw Normal View History

2026-05-19 17:56:12 -04:00
#!/usr/bin/env python3
"""
Phone Recorder Server
A FastAPI server that:
- Receives encrypted recordings from phone, decrypts and stores them
- Provides API for viewing recordings, transcripts, and highlights
- Supports importing transcriptions from batch processing
"""
import hashlib
import mimetypes
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, Header, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel
import config
import crypto
import database
import importer
app = FastAPI(
title="Phone Recorder Server",
description="Receives encrypted recordings and provides viewing API",
version="2.0.0",
)
# CORS for separate UI
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Request/Response models
class ImportRequest(BaseModel):
path: str
source_name: str = "phone"
audio_path: Optional[str] = None
class ImportAudioRequest(BaseModel):
path: str
source_name: str
class HighlightCreate(BaseModel):
recording_id: int
start_seconds: float
end_seconds: Optional[float] = None # None = point highlight, value = range
tag: str = "important"
note: Optional[str] = None
segment_id: Optional[int] = None
class HighlightUpdate(BaseModel):
tag: Optional[str] = None
note: Optional[str] = None
# Startup
@app.on_event("startup")
async def startup_event():
"""Initialize server on startup."""
# Initialize database
database.init_db()
print(f"Database initialized: {config.DATABASE_PATH}")
# Ensure keypair exists
crypto.load_or_create_keypair()
print(f"Server public key (Base64): {crypto.get_public_key_base64()}")
print(f"Recordings directory: {config.RECORDINGS_DIR}")
print(f"Audio base path: {config.AUDIO_BASE_PATH}")
# Health & Info
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "ok"}
@app.get("/api/v1/info")
async def server_info():
"""Server configuration info."""
return {
"recordings_dir": str(config.RECORDINGS_DIR),
"audio_base_path": str(config.AUDIO_BASE_PATH),
"database_path": str(config.DATABASE_PATH),
}
@app.get("/api/v1/public-key")
async def get_public_key():
"""Returns the server's public key for client encryption."""
return {"public_key": crypto.get_public_key_base64()}
# Upload (existing functionality)
@app.post("/api/v1/recordings/upload")
async def upload_recording(
file: UploadFile = File(...),
x_api_key: str = Header(None, alias="X-API-Key"),
):
"""
Receives an encrypted recording, decrypts it, and stores it.
The file is expected to be in the SREC encrypted format.
Returns a SHA-256 hash of the received encrypted file for verification.
Requires X-API-Key header if API_KEY is configured.
"""
# Check API key if configured
if config.API_KEY and x_api_key != config.API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
if not file.filename.endswith(".ogg.enc"):
raise HTTPException(status_code=400, detail="Invalid file format")
temp_file = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as temp:
temp_file = Path(temp.name)
content = await file.read()
temp.write(content)
received_hash = hashlib.sha256(content).hexdigest()
output_path = crypto.get_output_path_for_recording(file.filename)
if output_path.exists():
return JSONResponse(
status_code=200,
content={
"message": "Recording already exists",
"path": str(output_path),
"received_hash": received_hash,
"verified": True,
}
)
success = crypto.decrypt_file(temp_file, output_path)
if not success:
raise HTTPException(status_code=400, detail="Decryption failed")
# Create database entry for the recording
recorded_at = None
# Parse timestamp from filename: recording_2026-05-11_18-45-24.ogg.enc
try:
base_name = file.filename.replace(".ogg.enc", "").replace("recording_", "")
recorded_at = datetime.strptime(base_name, "%Y-%m-%d_%H-%M-%S")
except ValueError:
pass # Use None if parsing fails
# Check if already in database
existing = database.get_recording_by_source_file(file.filename)
if not existing:
database.create_recording(
source_file=file.filename,
filename=output_path.name,
source_name="phone",
audio_path=str(output_path),
recorded_at=recorded_at,
)
return {
"message": "Recording uploaded and decrypted successfully",
"path": str(output_path.relative_to(config.RECORDINGS_DIR)),
"received_hash": received_hash,
"verified": True,
}
finally:
if temp_file and temp_file.exists():
temp_file.unlink()
# Recordings API
@app.get("/api/v1/recordings")
async def list_recordings(
source: Optional[str] = Query(None, description="Filter by source name"),
date_from: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
):
"""List recordings with optional filters."""
recordings = database.list_recordings(
source_name=source,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset,
)
total = database.get_recordings_count(source_name=source)
return {
"recordings": recordings,
"count": len(recordings),
"total": total,
"limit": limit,
"offset": offset,
}
@app.get("/api/v1/recordings/{recording_id}")
async def get_recording(recording_id: int):
"""Get recording details with transcript segments and highlights."""
recording = database.get_recording(recording_id)
if not recording:
raise HTTPException(status_code=404, detail="Recording not found")
return recording
@app.get("/api/v1/recordings/{recording_id}/audio")
async def get_recording_audio(recording_id: int):
"""Stream the audio file for a recording."""
recording = database.get_recording(recording_id)
if not recording:
raise HTTPException(status_code=404, detail="Recording not found")
audio_path = recording.get("audio_path")
if not audio_path:
raise HTTPException(status_code=404, detail="Audio file path not set")
audio_file = Path(audio_path)
if not audio_file.exists():
# Try finding in audio base path
audio_file = config.AUDIO_BASE_PATH / Path(audio_path).name
if not audio_file.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
media_type = mimetypes.guess_type(str(audio_file))[0] or "audio/ogg"
return FileResponse(
audio_file,
media_type=media_type,
filename=audio_file.name,
)
# Import API
@app.post("/api/v1/import/transcriptions")
async def import_transcriptions(request: ImportRequest):
"""
Import transcription JSON files from a directory.
Idempotent: already-imported files are skipped.
"""
path = Path(request.path)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Path not found: {path}")
audio_base = Path(request.audio_path) if request.audio_path else config.AUDIO_BASE_PATH
if path.is_file():
result = importer.import_transcription_json(
path,
source_name=request.source_name,
audio_base_path=audio_base,
)
return result
else:
result = importer.import_transcription_directory(
path,
source_name=request.source_name,
audio_base_path=audio_base,
)
return result
@app.post("/api/v1/import/audio")
async def import_audio(request: ImportAudioRequest):
"""
Import audio files from a directory (without transcriptions).
Useful for importing recordings from other sources.
"""
path = Path(request.path)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Path not found: {path}")
if path.is_file():
result = importer.import_audio_file(path, source_name=request.source_name)
return result
else:
result = importer.import_audio_directory(path, source_name=request.source_name)
return result
@app.post("/api/v1/import/link-audio")
async def link_audio(audio_dir: str = Query(..., description="Directory containing audio files")):
"""
Link audio files to existing transcription records.
Matches by filename pattern.
"""
path = Path(audio_dir)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Directory not found: {path}")
result = importer.link_audio_to_transcriptions(path)
return result
# Highlights API
@app.get("/api/v1/highlights")
async def list_highlights(
recording_id: Optional[int] = Query(None),
tag: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000),
):
"""List highlights with optional filters."""
highlights = database.list_highlights(
recording_id=recording_id,
tag=tag,
limit=limit,
)
return {"highlights": highlights, "count": len(highlights)}
@app.post("/api/v1/highlights")
async def create_highlight(highlight: HighlightCreate):
"""Create a new highlight/bookmark."""
# Verify recording exists
recording = database.get_recording(highlight.recording_id)
if not recording:
raise HTTPException(status_code=404, detail="Recording not found")
highlight_id = database.create_highlight(
recording_id=highlight.recording_id,
start_seconds=highlight.start_seconds,
end_seconds=highlight.end_seconds,
tag=highlight.tag,
note=highlight.note,
segment_id=highlight.segment_id,
)
return {"id": highlight_id, "message": "Highlight created"}
@app.delete("/api/v1/highlights/{highlight_id}")
async def delete_highlight(highlight_id: int):
"""Delete a highlight."""
success = database.delete_highlight(highlight_id)
if not success:
raise HTTPException(status_code=404, detail="Highlight not found")
return {"message": "Highlight deleted"}
# Sources/Devices API
@app.get("/api/v1/sources")
async def list_sources():
"""Get summary of all recording sources."""
sources = database.list_sources()
return {"sources": sources}
# Legacy endpoint (for backwards compatibility)
@app.get("/api/v1/recordings/files")
async def list_recording_files(date: Optional[str] = Query(None, description="Filter by date (YYYY-MM-DD)")):
"""
Lists recording files on disk (legacy endpoint).
For new integrations, use GET /api/v1/recordings instead.
"""
recordings = []
if date:
try:
dt = datetime.strptime(date, "%Y-%m-%d")
search_dir = config.RECORDINGS_DIR / str(dt.year) / f"{dt.month:02d}" / f"{dt.day:02d}"
if search_dir.exists():
recordings = [f.name for f in search_dir.glob("*.ogg")]
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
else:
for ogg_file in config.RECORDINGS_DIR.rglob("*.ogg"):
rel_path = ogg_file.relative_to(config.RECORDINGS_DIR)
recordings.append(str(rel_path))
recordings.sort()
return {"recordings": recordings, "count": len(recordings)}
if __name__ == "__main__":
import uvicorn
print(f"Starting server on {config.HOST}:{config.PORT}")
uvicorn.run(
"main:app",
host=config.HOST,
port=config.PORT,
reload=True,
)