#!/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, )