421 lines
13 KiB
Python
421 lines
13 KiB
Python
#!/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 Depends, FastAPI, File, Header, HTTPException, Query, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
import config
|
|
|
|
|
|
async def verify_api_key(x_api_key: str = Header(None, alias="X-API-Key")):
|
|
"""Verify API key for protected endpoints."""
|
|
if not config.API_KEY:
|
|
raise HTTPException(status_code=500, detail="API_KEY not configured on server")
|
|
if x_api_key != config.API_KEY:
|
|
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
return x_api_key
|
|
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", dependencies=[Depends(verify_api_key)])
|
|
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", dependencies=[Depends(verify_api_key)])
|
|
async def upload_recording(
|
|
file: UploadFile = File(...),
|
|
):
|
|
"""
|
|
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 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", dependencies=[Depends(verify_api_key)])
|
|
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}", dependencies=[Depends(verify_api_key)])
|
|
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", dependencies=[Depends(verify_api_key)])
|
|
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", dependencies=[Depends(verify_api_key)])
|
|
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", dependencies=[Depends(verify_api_key)])
|
|
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", dependencies=[Depends(verify_api_key)])
|
|
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", dependencies=[Depends(verify_api_key)])
|
|
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", dependencies=[Depends(verify_api_key)])
|
|
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}", dependencies=[Depends(verify_api_key)])
|
|
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", dependencies=[Depends(verify_api_key)])
|
|
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", dependencies=[Depends(verify_api_key)])
|
|
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,
|
|
)
|