"""Session recording API endpoints.""" from __future__ import annotations import json from pathlib import Path from flask import Blueprint, request, send_file from utils.recording import RECORDING_ROOT, get_recording_manager from utils.responses import api_error, api_success recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings') @recordings_bp.route('/start', methods=['POST']) def start_recording(): data = request.get_json() or {} mode = (data.get('mode') or '').strip() if not mode: return api_error('mode is required', 400) label = data.get('label') metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} manager = get_recording_manager() session = manager.start_recording(mode=mode, label=label, metadata=metadata) return api_success(data={'session': { 'id': session.id, 'mode': session.mode, 'label': session.label, 'started_at': session.started_at.isoformat(), 'file_path': str(session.file_path), }}) @recordings_bp.route('/stop', methods=['POST']) def stop_recording(): data = request.get_json() or {} mode = data.get('mode') session_id = data.get('id') manager = get_recording_manager() session = manager.stop_recording(mode=mode, session_id=session_id) if not session: return api_error('No active recording found', 404) return api_success(data={'session': { 'id': session.id, 'mode': session.mode, 'label': session.label, 'started_at': session.started_at.isoformat(), 'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None, 'event_count': session.event_count, 'size_bytes': session.size_bytes, 'file_path': str(session.file_path), }}) @recordings_bp.route('', methods=['GET']) def list_recordings(): manager = get_recording_manager() limit = request.args.get('limit', default=50, type=int) return api_success(data={ 'recordings': manager.list_recordings(limit=limit), 'active': manager.get_active(), }) @recordings_bp.route('/', methods=['GET']) def get_recording(session_id: str): manager = get_recording_manager() rec = manager.get_recording(session_id) if not rec: return api_error('Recording not found', 404) return api_success(data={'recording': rec}) @recordings_bp.route('//download', methods=['GET']) def download_recording(session_id: str): manager = get_recording_manager() rec = manager.get_recording(session_id) if not rec: return api_error('Recording not found', 404) file_path = Path(rec['file_path']) try: resolved_root = RECORDING_ROOT.resolve() resolved_file = file_path.resolve() if resolved_root not in resolved_file.parents: return api_error('Invalid recording path', 400) except Exception: return api_error('Invalid recording path', 400) if not file_path.exists(): return api_error('Recording file missing', 404) return send_file( file_path, mimetype='application/x-ndjson', as_attachment=True, download_name=file_path.name, ) @recordings_bp.route('//events', methods=['GET']) def get_recording_events(session_id: str): """Return parsed events from a recording for in-app replay.""" manager = get_recording_manager() rec = manager.get_recording(session_id) if not rec: return api_error('Recording not found', 404) file_path = Path(rec['file_path']) try: resolved_root = RECORDING_ROOT.resolve() resolved_file = file_path.resolve() if resolved_root not in resolved_file.parents: return api_error('Invalid recording path', 400) except Exception: return api_error('Invalid recording path', 400) if not file_path.exists(): return api_error('Recording file missing', 404) limit = max(1, min(5000, request.args.get('limit', default=500, type=int))) offset = max(0, request.args.get('offset', default=0, type=int)) events: list[dict] = [] seen = 0 with file_path.open('r', encoding='utf-8', errors='replace') as fh: for idx, line in enumerate(fh): if idx < offset: continue if seen >= limit: break line = line.strip() if not line: continue try: events.append(json.loads(line)) seen += 1 except json.JSONDecodeError: continue return api_success(data={ 'recording': { 'id': rec['id'], 'mode': rec['mode'], 'started_at': rec['started_at'], 'stopped_at': rec['stopped_at'], 'event_count': rec['event_count'], }, 'offset': offset, 'limit': limit, 'returned': len(events), 'events': events, })