This commit is contained in:
parent
f5305c10d8
commit
30999a2cb2
11 changed files with 2486 additions and 0 deletions
416
clio-api/main.py
Normal file
416
clio-api/main.py
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
#!/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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue