""" BreakPilot Recording API Verwaltet Jibri Meeting-Aufzeichnungen und deren Metadaten. Empfaengt Webhooks von Jibri nach Upload zu MinIO. """ import os import uuid from datetime import datetime, timedelta from typing import Optional, List from pydantic import BaseModel, Field from fastapi import APIRouter, HTTPException, Query, Depends, Request from fastapi.responses import JSONResponse router = APIRouter(prefix="/api/recordings", tags=["Recordings"]) # ========================================== # ENVIRONMENT CONFIGURATION # ========================================== MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000") MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "breakpilot") MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "breakpilot123") MINIO_BUCKET = os.getenv("MINIO_BUCKET", "breakpilot-recordings") MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true" # Default retention period in days (DSGVO compliance) DEFAULT_RETENTION_DAYS = int(os.getenv("RECORDING_RETENTION_DAYS", "365")) # ========================================== # PYDANTIC MODELS # ========================================== class JibriWebhookPayload(BaseModel): """Webhook payload from Jibri finalize.sh script.""" event: str = Field(..., description="Event type: recording_completed") recording_name: str = Field(..., description="Unique recording identifier") storage_path: str = Field(..., description="Path in MinIO bucket") audio_path: Optional[str] = Field(None, description="Extracted audio path") file_size_bytes: int = Field(..., description="Video file size in bytes") timestamp: str = Field(..., description="ISO timestamp of upload") class RecordingCreate(BaseModel): """Manual recording creation (for testing).""" meeting_id: str title: Optional[str] = None storage_path: str audio_path: Optional[str] = None duration_seconds: Optional[int] = None participant_count: Optional[int] = 0 retention_days: Optional[int] = DEFAULT_RETENTION_DAYS class RecordingResponse(BaseModel): """Recording details response.""" id: str meeting_id: str title: Optional[str] storage_path: str audio_path: Optional[str] file_size_bytes: Optional[int] duration_seconds: Optional[int] participant_count: int status: str recorded_at: datetime retention_days: int retention_expires_at: datetime transcription_status: Optional[str] = None transcription_id: Optional[str] = None class RecordingListResponse(BaseModel): """Paginated list of recordings.""" recordings: List[RecordingResponse] total: int page: int page_size: int class TranscriptionRequest(BaseModel): """Request to start transcription.""" language: str = Field(default="de", description="Language code: de, en, etc.") model: str = Field(default="large-v3", description="Whisper model to use") priority: int = Field(default=0, description="Queue priority (higher = sooner)") class TranscriptionStatusResponse(BaseModel): """Transcription status and progress.""" id: str recording_id: str status: str language: str model: str word_count: Optional[int] confidence_score: Optional[float] processing_duration_seconds: Optional[int] error_message: Optional[str] created_at: datetime completed_at: Optional[datetime] # ========================================== # IN-MEMORY STORAGE (Dev Mode) # ========================================== # In production, these would be database queries _recordings_store: dict = {} _transcriptions_store: dict = {} _audit_log: list = [] def log_audit( action: str, recording_id: Optional[str] = None, transcription_id: Optional[str] = None, user_id: Optional[str] = None, metadata: Optional[dict] = None ): """Log audit event for DSGVO compliance.""" _audit_log.append({ "id": str(uuid.uuid4()), "recording_id": recording_id, "transcription_id": transcription_id, "user_id": user_id, "action": action, "metadata": metadata or {}, "created_at": datetime.utcnow().isoformat() }) # ========================================== # WEBHOOK ENDPOINT (Jibri) # ========================================== @router.post("/webhook") async def jibri_webhook(payload: JibriWebhookPayload, request: Request): """ Webhook endpoint called by Jibri finalize.sh after upload. This creates a new recording entry and optionally triggers transcription. """ if payload.event != "recording_completed": return JSONResponse( status_code=400, content={"error": f"Unknown event type: {payload.event}"} ) # Extract meeting_id from recording_name (format: meetingId_timestamp) parts = payload.recording_name.split("_") meeting_id = parts[0] if parts else payload.recording_name # Create recording entry recording_id = str(uuid.uuid4()) recorded_at = datetime.utcnow() recording = { "id": recording_id, "meeting_id": meeting_id, "jibri_session_id": payload.recording_name, "title": f"Recording {meeting_id}", "storage_path": payload.storage_path, "audio_path": payload.audio_path, "file_size_bytes": payload.file_size_bytes, "duration_seconds": None, # Will be updated after analysis "participant_count": 0, "status": "uploaded", "recorded_at": recorded_at.isoformat(), "retention_days": DEFAULT_RETENTION_DAYS, "created_at": datetime.utcnow().isoformat(), "updated_at": datetime.utcnow().isoformat() } _recordings_store[recording_id] = recording # Log the creation log_audit( action="created", recording_id=recording_id, metadata={ "source": "jibri_webhook", "storage_path": payload.storage_path, "file_size_bytes": payload.file_size_bytes } ) return { "success": True, "recording_id": recording_id, "meeting_id": meeting_id, "status": "uploaded", "message": "Recording registered successfully" } # ========================================== # HEALTH & AUDIT ENDPOINTS (must be before parameterized routes) # ========================================== @router.get("/health") async def recordings_health(): """Health check for recording service.""" return { "status": "healthy", "recordings_count": len(_recordings_store), "transcriptions_count": len(_transcriptions_store), "minio_endpoint": MINIO_ENDPOINT, "bucket": MINIO_BUCKET } @router.get("/audit/log") async def get_audit_log( recording_id: Optional[str] = Query(None), action: Optional[str] = Query(None), limit: int = Query(100, ge=1, le=1000) ): """ Get audit log entries (DSGVO compliance). Admin-only endpoint for reviewing recording access history. """ logs = _audit_log.copy() if recording_id: logs = [l for l in logs if l.get("recording_id") == recording_id] if action: logs = [l for l in logs if l.get("action") == action] # Sort by created_at descending logs.sort(key=lambda x: x["created_at"], reverse=True) return { "entries": logs[:limit], "total": len(logs) } # ========================================== # RECORDING MANAGEMENT ENDPOINTS # ========================================== @router.get("", response_model=RecordingListResponse) async def list_recordings( status: Optional[str] = Query(None, description="Filter by status"), meeting_id: Optional[str] = Query(None, description="Filter by meeting ID"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(20, ge=1, le=100, description="Items per page") ): """ List all recordings with optional filtering. Supports pagination and filtering by status or meeting ID. """ # Filter recordings recordings = list(_recordings_store.values()) if status: recordings = [r for r in recordings if r["status"] == status] if meeting_id: recordings = [r for r in recordings if r["meeting_id"] == meeting_id] # Sort by recorded_at descending recordings.sort(key=lambda x: x["recorded_at"], reverse=True) # Paginate total = len(recordings) start = (page - 1) * page_size end = start + page_size page_recordings = recordings[start:end] # Convert to response format result = [] for rec in page_recordings: recorded_at = datetime.fromisoformat(rec["recorded_at"]) retention_expires = recorded_at + timedelta(days=rec["retention_days"]) # Check for transcription trans = next( (t for t in _transcriptions_store.values() if t["recording_id"] == rec["id"]), None ) result.append(RecordingResponse( id=rec["id"], meeting_id=rec["meeting_id"], title=rec.get("title"), storage_path=rec["storage_path"], audio_path=rec.get("audio_path"), file_size_bytes=rec.get("file_size_bytes"), duration_seconds=rec.get("duration_seconds"), participant_count=rec.get("participant_count", 0), status=rec["status"], recorded_at=recorded_at, retention_days=rec["retention_days"], retention_expires_at=retention_expires, transcription_status=trans["status"] if trans else None, transcription_id=trans["id"] if trans else None )) return RecordingListResponse( recordings=result, total=total, page=page, page_size=page_size ) @router.get("/{recording_id}", response_model=RecordingResponse) async def get_recording(recording_id: str): """ Get details for a specific recording. """ recording = _recordings_store.get(recording_id) if not recording: raise HTTPException(status_code=404, detail="Recording not found") # Log view action log_audit(action="viewed", recording_id=recording_id) recorded_at = datetime.fromisoformat(recording["recorded_at"]) retention_expires = recorded_at + timedelta(days=recording["retention_days"]) # Check for transcription trans = next( (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), None ) return RecordingResponse( id=recording["id"], meeting_id=recording["meeting_id"], title=recording.get("title"), storage_path=recording["storage_path"], audio_path=recording.get("audio_path"), file_size_bytes=recording.get("file_size_bytes"), duration_seconds=recording.get("duration_seconds"), participant_count=recording.get("participant_count", 0), status=recording["status"], recorded_at=recorded_at, retention_days=recording["retention_days"], retention_expires_at=retention_expires, transcription_status=trans["status"] if trans else None, transcription_id=trans["id"] if trans else None ) @router.delete("/{recording_id}") async def delete_recording( recording_id: str, reason: str = Query(..., description="Reason for deletion (DSGVO audit)") ): """ Soft-delete a recording (DSGVO compliance). The recording is marked as deleted but retained for audit purposes. Actual file deletion happens after the audit retention period. """ recording = _recordings_store.get(recording_id) if not recording: raise HTTPException(status_code=404, detail="Recording not found") # Soft delete recording["status"] = "deleted" recording["deleted_at"] = datetime.utcnow().isoformat() recording["updated_at"] = datetime.utcnow().isoformat() # Log deletion with reason log_audit( action="deleted", recording_id=recording_id, metadata={"reason": reason} ) return { "success": True, "recording_id": recording_id, "status": "deleted", "message": "Recording marked for deletion" } # ========================================== # TRANSCRIPTION ENDPOINTS # ========================================== @router.post("/{recording_id}/transcribe", response_model=TranscriptionStatusResponse) async def start_transcription(recording_id: str, request: TranscriptionRequest): """ Start transcription for a recording. Queues the recording for processing by the transcription worker. """ recording = _recordings_store.get(recording_id) if not recording: raise HTTPException(status_code=404, detail="Recording not found") if recording["status"] == "deleted": raise HTTPException(status_code=400, detail="Cannot transcribe deleted recording") # Check if transcription already exists existing = next( (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id and t["status"] != "failed"), None ) if existing: raise HTTPException( status_code=409, detail=f"Transcription already exists with status: {existing['status']}" ) # Create transcription entry transcription_id = str(uuid.uuid4()) now = datetime.utcnow() transcription = { "id": transcription_id, "recording_id": recording_id, "language": request.language, "model": request.model, "status": "pending", "full_text": None, "word_count": None, "confidence_score": None, "vtt_path": None, "srt_path": None, "json_path": None, "error_message": None, "processing_started_at": None, "processing_completed_at": None, "processing_duration_seconds": None, "created_at": now.isoformat(), "updated_at": now.isoformat() } _transcriptions_store[transcription_id] = transcription # Update recording status recording["status"] = "processing" recording["updated_at"] = now.isoformat() # Log transcription start log_audit( action="transcription_started", recording_id=recording_id, transcription_id=transcription_id, metadata={"language": request.language, "model": request.model} ) # TODO: Queue job to Redis/Valkey for transcription worker # from redis import Redis # from rq import Queue # q = Queue(connection=Redis.from_url(os.getenv("REDIS_URL"))) # q.enqueue('transcription_worker.tasks.transcribe', transcription_id, ...) return TranscriptionStatusResponse( id=transcription_id, recording_id=recording_id, status="pending", language=request.language, model=request.model, word_count=None, confidence_score=None, processing_duration_seconds=None, error_message=None, created_at=now, completed_at=None ) @router.get("/{recording_id}/transcription", response_model=TranscriptionStatusResponse) async def get_transcription_status(recording_id: str): """ Get transcription status for a recording. """ transcription = next( (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), None ) if not transcription: raise HTTPException(status_code=404, detail="No transcription found for this recording") return TranscriptionStatusResponse( id=transcription["id"], recording_id=transcription["recording_id"], status=transcription["status"], language=transcription["language"], model=transcription["model"], word_count=transcription.get("word_count"), confidence_score=transcription.get("confidence_score"), processing_duration_seconds=transcription.get("processing_duration_seconds"), error_message=transcription.get("error_message"), created_at=datetime.fromisoformat(transcription["created_at"]), completed_at=( datetime.fromisoformat(transcription["processing_completed_at"]) if transcription.get("processing_completed_at") else None ) ) @router.get("/{recording_id}/transcription/text") async def get_transcription_text(recording_id: str): """ Get the full transcription text. """ transcription = next( (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), None ) if not transcription: raise HTTPException(status_code=404, detail="No transcription found for this recording") if transcription["status"] != "completed": raise HTTPException( status_code=400, detail=f"Transcription not ready. Status: {transcription['status']}" ) return { "transcription_id": transcription["id"], "recording_id": recording_id, "language": transcription["language"], "text": transcription.get("full_text", ""), "word_count": transcription.get("word_count", 0) } @router.get("/{recording_id}/transcription/vtt") async def get_transcription_vtt(recording_id: str): """ Download transcription as WebVTT subtitle file. """ from fastapi.responses import PlainTextResponse transcription = next( (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), None ) if not transcription: raise HTTPException(status_code=404, detail="No transcription found for this recording") if transcription["status"] != "completed": raise HTTPException( status_code=400, detail=f"Transcription not ready. Status: {transcription['status']}" ) # Generate VTT content # In production, this would read from the stored VTT file vtt_content = "WEBVTT\n\n" text = transcription.get("full_text", "") if text: # Simple VTT generation - split into sentences sentences = text.replace(".", ".\n").split("\n") time_offset = 0 for sentence in sentences: sentence = sentence.strip() if sentence: start = format_vtt_time(time_offset) # Estimate ~3 seconds per sentence time_offset += 3000 end = format_vtt_time(time_offset) vtt_content += f"{start} --> {end}\n{sentence}\n\n" return PlainTextResponse( content=vtt_content, media_type="text/vtt", headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.vtt"} ) @router.get("/{recording_id}/transcription/srt") async def get_transcription_srt(recording_id: str): """ Download transcription as SRT subtitle file. """ from fastapi.responses import PlainTextResponse transcription = next( (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), None ) if not transcription: raise HTTPException(status_code=404, detail="No transcription found for this recording") if transcription["status"] != "completed": raise HTTPException( status_code=400, detail=f"Transcription not ready. Status: {transcription['status']}" ) # Generate SRT content srt_content = "" text = transcription.get("full_text", "") if text: sentences = text.replace(".", ".\n").split("\n") time_offset = 0 index = 1 for sentence in sentences: sentence = sentence.strip() if sentence: start = format_srt_time(time_offset) time_offset += 3000 end = format_srt_time(time_offset) srt_content += f"{index}\n{start} --> {end}\n{sentence}\n\n" index += 1 return PlainTextResponse( content=srt_content, media_type="text/plain", headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.srt"} ) def format_vtt_time(ms: int) -> str: """Format milliseconds to VTT timestamp (HH:MM:SS.mmm).""" hours = ms // 3600000 minutes = (ms % 3600000) // 60000 seconds = (ms % 60000) // 1000 millis = ms % 1000 return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}" def format_srt_time(ms: int) -> str: """Format milliseconds to SRT timestamp (HH:MM:SS,mmm).""" hours = ms // 3600000 minutes = (ms % 3600000) // 60000 seconds = (ms % 60000) // 1000 millis = ms % 1000 return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}" @router.get("/{recording_id}/download") async def download_recording(recording_id: str): """ Download the recording file. In production, this would generate a presigned URL to MinIO. """ recording = _recordings_store.get(recording_id) if not recording: raise HTTPException(status_code=404, detail="Recording not found") if recording["status"] == "deleted": raise HTTPException(status_code=410, detail="Recording has been deleted") # Log download action log_audit(action="downloaded", recording_id=recording_id) # In production, generate presigned URL to MinIO # For now, return info about where the file is return { "recording_id": recording_id, "storage_path": recording["storage_path"], "file_size_bytes": recording.get("file_size_bytes"), "message": "In production, this would redirect to a presigned MinIO URL" } # ========================================== # MEETING MINUTES ENDPOINTS # ========================================== # In-memory store for meeting minutes (dev mode) _minutes_store: dict = {} @router.post("/{recording_id}/minutes") async def generate_meeting_minutes( recording_id: str, title: Optional[str] = Query(None, description="Meeting-Titel"), model: str = Query("breakpilot-teacher-8b", description="LLM Modell") ): """ Generiert KI-basierte Meeting Minutes aus der Transkription. Nutzt das LLM Gateway (Ollama/vLLM) fuer lokale Verarbeitung. """ from meeting_minutes_generator import get_minutes_generator, MeetingMinutes # Check recording exists recording = _recordings_store.get(recording_id) if not recording: raise HTTPException(status_code=404, detail="Recording not found") # Check transcription exists and is completed transcription = next( (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), None ) if not transcription: raise HTTPException(status_code=400, detail="No transcription found. Please transcribe first.") if transcription["status"] != "completed": raise HTTPException( status_code=400, detail=f"Transcription not ready. Status: {transcription['status']}" ) # Check if minutes already exist existing = _minutes_store.get(recording_id) if existing and existing.get("status") == "completed": # Return existing minutes return existing # Get transcript text transcript_text = transcription.get("full_text", "") if not transcript_text: raise HTTPException(status_code=400, detail="Transcription has no text content") # Generate meeting minutes generator = get_minutes_generator() try: minutes = await generator.generate( transcript=transcript_text, recording_id=recording_id, transcription_id=transcription["id"], title=title, date=recording.get("recorded_at", "")[:10] if recording.get("recorded_at") else None, duration_minutes=recording.get("duration_seconds", 0) // 60 if recording.get("duration_seconds") else None, participant_count=recording.get("participant_count", 0), model=model ) # Store minutes minutes_dict = minutes.model_dump() minutes_dict["generated_at"] = minutes.generated_at.isoformat() _minutes_store[recording_id] = minutes_dict # Log action log_audit( action="minutes_generated", recording_id=recording_id, metadata={"model": model, "generation_time": minutes.generation_time_seconds} ) return minutes_dict except Exception as e: raise HTTPException(status_code=500, detail=f"Minutes generation failed: {str(e)}") @router.get("/{recording_id}/minutes") async def get_meeting_minutes(recording_id: str): """ Ruft generierte Meeting Minutes ab. """ minutes = _minutes_store.get(recording_id) if not minutes: raise HTTPException(status_code=404, detail="No meeting minutes found. Generate them first with POST.") return minutes @router.get("/{recording_id}/minutes/markdown") async def get_minutes_markdown(recording_id: str): """ Exportiert Meeting Minutes als Markdown. """ from fastapi.responses import PlainTextResponse from meeting_minutes_generator import minutes_to_markdown, MeetingMinutes minutes_dict = _minutes_store.get(recording_id) if not minutes_dict: raise HTTPException(status_code=404, detail="No meeting minutes found") # Convert dict back to MeetingMinutes minutes_dict_copy = minutes_dict.copy() if isinstance(minutes_dict_copy.get("generated_at"), str): minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"]) minutes = MeetingMinutes(**minutes_dict_copy) markdown = minutes_to_markdown(minutes) return PlainTextResponse( content=markdown, media_type="text/markdown", headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.md"} ) @router.get("/{recording_id}/minutes/html") async def get_minutes_html(recording_id: str): """ Exportiert Meeting Minutes als HTML. """ from fastapi.responses import HTMLResponse from meeting_minutes_generator import minutes_to_html, MeetingMinutes minutes_dict = _minutes_store.get(recording_id) if not minutes_dict: raise HTTPException(status_code=404, detail="No meeting minutes found") # Convert dict back to MeetingMinutes minutes_dict_copy = minutes_dict.copy() if isinstance(minutes_dict_copy.get("generated_at"), str): minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"]) minutes = MeetingMinutes(**minutes_dict_copy) html = minutes_to_html(minutes) return HTMLResponse(content=html) @router.get("/{recording_id}/minutes/pdf") async def get_minutes_pdf(recording_id: str): """ Exportiert Meeting Minutes als PDF. Benoetigt WeasyPrint (pip install weasyprint). """ from meeting_minutes_generator import minutes_to_html, MeetingMinutes minutes_dict = _minutes_store.get(recording_id) if not minutes_dict: raise HTTPException(status_code=404, detail="No meeting minutes found") # Convert dict back to MeetingMinutes minutes_dict_copy = minutes_dict.copy() if isinstance(minutes_dict_copy.get("generated_at"), str): minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"]) minutes = MeetingMinutes(**minutes_dict_copy) html = minutes_to_html(minutes) try: from weasyprint import HTML from fastapi.responses import Response pdf_bytes = HTML(string=html).write_pdf() return Response( content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.pdf"} ) except ImportError: raise HTTPException( status_code=501, detail="PDF export not available. Install weasyprint: pip install weasyprint" )