From a66f963232e7ef8fad32f740fa580dccd9408c28 Mon Sep 17 00:00:00 2001 From: Anton Kress Date: Mon, 1 Jun 2026 17:08:52 +0200 Subject: [PATCH] feat(recording): auto-record on go-live, server-driven finalize, surface write failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make show recording automatic and resilient to the browser lifecycle, and stop silently swallowing recording write failures. Closes #169 (Phase 1 of #164). - Auto-start recording when a live stream goes live: the show id is passed via `/ws/stream?show_id=N` and the backend starts the tee on connect (no separate frontend call). Reconnects are a no-op that resume the existing archive. - Grace-period finalize: a WS drop no longer ends the recording. A 30s timer (FINALIZE_GRACE) lets a reconnect resume the same archive instead of fragmenting it; explicit stop ('stop' msg / POST /api/stream/stop) finalizes immediately. Decouples the archive from a dropped tab. - Extract finalize_and_upload(state) + ensure_recording_started(state, show_id) as the single source of truth, reused by the HTTP stop endpoint, the WS lifecycle, and the grace task (idempotent: one upload per session). - Surface recording write failures: write_chunk now logs at error, sets recording_failed on StreamStatus, drops the corrupt file handle, and marks the DB recording version 'failed' — instead of warn!+Ok. Live stream is unaffected. - Frontend: useStreamSocket.connect(force, showId) carries show_id across reconnects; FlowWaiting/FlowOnAir drop their recordingApi.start() calls. --- backend/src/handlers/recording.rs | 203 ++++++++++++++---- backend/src/handlers/stream_ws.rs | 152 +++++++++++-- backend/src/main.rs | 8 + backend/src/stream_bridge.rs | 34 ++- .../src/admin/composables/useStreamSocket.ts | 17 +- frontend/src/admin/pages/flow/FlowOnAir.vue | 11 +- frontend/src/admin/pages/flow/FlowWaiting.vue | 18 +- 7 files changed, 366 insertions(+), 77 deletions(-) diff --git a/backend/src/handlers/recording.rs b/backend/src/handlers/recording.rs index 13e5bdc..7c9ce81 100644 --- a/backend/src/handlers/recording.rs +++ b/backend/src/handlers/recording.rs @@ -221,43 +221,87 @@ pub async fn add_marker( /// /// Stop the current recording session and upload raw recording + markers to R2. /// Also stops the stream from tee-ing audio chunks. +/// +/// Recording is normally finalized automatically by the stream lifecycle (see +/// [`finalize_and_upload`]); this endpoint remains for explicit/manual stops. pub async fn stop_recording( State(state): State>, - State(recording_manager): State, - State(stream_state): State, + State(_recording_manager): State, + State(_stream_state): State, headers: HeaderMap, ) -> Result { // Require admin authentication let _user = require_admin(&state, &headers).await?; - // Stop the stream recording first (flushes and closes the file) - { - let mut stream = stream_state.lock().await; + match finalize_and_upload(&state).await? { + Some(result) => Ok(( + StatusCode::OK, + Json(StopRecordingResponse { + success: true, + message: format!("Recording stopped and uploaded for show {}", result.show_id), + show_id: result.show_id, + version: result.version, + marker_count: result.marker_count, + raw_key: Some(result.raw_key), + markers_key: Some(result.markers_key), + }), + )), + None => Err(AppError::BadRequest( + "No recording session was active".to_string(), + )), + } +} + +/// Outcome of finalizing a recording session. +pub struct FinalizedRecording { + pub show_id: i64, + pub version: String, + pub marker_count: usize, + pub raw_key: String, + pub markers_key: String, + /// True if a write failure occurred mid-recording, so the raw archive is + /// incomplete (the DB version is marked `failed`). + pub incomplete: bool, +} + +/// Stop the active recording (if any), upload the raw recording + markers to R2, +/// and record a `recording_versions` row. +/// +/// This is the single source of truth for ending a recording. It is invoked both +/// by the explicit stop endpoint and by the stream lifecycle (auto-record on +/// go-live, grace-period finalize on disconnect). Returns `Ok(None)` when no +/// session was active, so callers can treat a no-op as success. +pub async fn finalize_and_upload(state: &Arc) -> Result> { + // Stop the stream tee first (flushes + closes the file) and capture whether + // a write failure had already abandoned the recording. + let write_failure = { + let mut stream = state.stream_state.lock().await; + let failure = stream.recording_failure().map(|s| s.to_string()); if let Err(e) = stream.stop_recording().await { tracing::warn!("Error stopping stream recording: {}", e); - // Continue anyway - we still want to process the session + // Continue anyway - we still want to process the session. } - } + failure + }; - // Stop the recording session - let mut manager = recording_manager.lock().await; - let session = manager - .stop() - .await - .map_err(|e| AppError::Internal(e.to_string()))?; + // Stop the recording session. + let session = { + let mut manager = state.recording_manager.lock().await; + manager + .stop() + .await + .map_err(|e| AppError::Internal(e.to_string()))? + }; let session = match session { Some(s) => s, - None => { - return Err(AppError::BadRequest( - "No recording session was active".to_string(), - )); - } + None => return Ok(None), }; let show_id = session.show_id; let version = session.version_timestamp.clone(); let marker_count = session.markers.len(); + let incomplete = write_failure.is_some(); // Upload raw recording to R2 let raw_key = format!("recordings/{}/{}/raw.webm", show_id, version); @@ -300,7 +344,7 @@ pub async fn stop_recording( tracing::info!("Uploaded markers to {}", markers_key); // Create recording version entry in database - if let Err(e) = crate::db::create_recording_version( + match crate::db::create_recording_version( &state.db, show_id, &version, @@ -310,14 +354,36 @@ pub async fn stop_recording( ) .await { - tracing::error!("Failed to create recording version in database: {}", e); - // Don't fail the request - the recording was uploaded successfully - } else { - tracing::info!( - "Created recording version in database: show_id={}, version={}", - show_id, - version - ); + Ok(recording) => { + tracing::info!( + "Created recording version in database: show_id={}, version={}", + show_id, + version + ); + // Mark incomplete recordings as failed so the operator sees the gap. + if let Some(ref err) = write_failure { + let msg = format!("Recording write failed mid-stream: {}", err); + tracing::error!( + "Marking recording version {} as failed: {}", + recording.id, + msg + ); + if let Err(e) = crate::db::update_recording_version_status( + &state.db, + recording.id, + "failed", + Some(&msg), + ) + .await + { + tracing::error!("Failed to mark recording version as failed: {}", e); + } + } + } + Err(e) => { + tracing::error!("Failed to create recording version in database: {}", e); + // Don't fail the finalize - the recording was uploaded successfully. + } } // Clean up temp file @@ -325,18 +391,77 @@ pub async fn stop_recording( tracing::warn!("Failed to clean up temp file: {}", e); } - Ok(( - StatusCode::OK, - Json(StopRecordingResponse { - success: true, - message: format!("Recording stopped and uploaded for show {}", show_id), - show_id, - version, - marker_count, - raw_key: Some(raw_key), - markers_key: Some(markers_key), - }), - )) + Ok(Some(FinalizedRecording { + show_id, + version, + marker_count, + raw_key, + markers_key, + incomplete, + })) +} + +/// Ensure a recording session is running for `show_id`, idempotently. +/// +/// Called when a live stream connects (auto-record on go-live). Safe to call on +/// every (re)connect: +/// - If already recording this show, it's a no-op (a transient WS reconnect must +/// NOT restart the archive or truncate the tee file). +/// - If recording a *different* show, the existing session is left untouched and +/// a warning is logged (the caller decides whether to take over). +/// - Otherwise a new session is started and the stream tee is pointed at it. +pub async fn ensure_recording_started(state: &Arc, show_id: i64) -> Result<()> { + // Validate the show exists before recording for it. + let show: Option = sqlx::query_as("SELECT * FROM shows WHERE id = ?") + .bind(show_id) + .fetch_optional(&state.db) + .await?; + if show.is_none() { + tracing::warn!( + "Auto-record skipped: show {} not found (stream will continue unrecorded)", + show_id + ); + return Ok(()); + } + + let temp_path = { + let mut manager = state.recording_manager.lock().await; + match manager.current_show_id() { + Some(current) if current == show_id => { + // Already recording this show (reconnect) — keep the existing tee. + return Ok(()); + } + Some(current) => { + tracing::warn!( + "Stream for show {} connected while recording show {}; leaving existing recording untouched", + show_id, + current + ); + return Ok(()); + } + None => { + manager + .start(show_id) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + manager.get_temp_file_path().ok_or_else(|| { + AppError::Internal( + "Recording session started but no temp file path available".to_string(), + ) + })? + } + } + }; + + // Point the stream tee at the new recording file. + let mut stream = state.stream_state.lock().await; + stream + .start_recording(temp_path) + .await + .map_err(|e| AppError::Internal(format!("Failed to start stream recording: {}", e)))?; + + tracing::info!("Auto-started recording for show {}", show_id); + Ok(()) } /// GET /api/shows/:id/recordings diff --git a/backend/src/handlers/stream_ws.rs b/backend/src/handlers/stream_ws.rs index 2f30559..dca7b3b 100644 --- a/backend/src/handlers/stream_ws.rs +++ b/backend/src/handlers/stream_ws.rs @@ -18,8 +18,16 @@ pub struct StreamQuery { /// If true, forcefully take over an existing stream #[serde(default)] pub force: bool, + /// Show being broadcast. When present, recording auto-starts for this show + /// on go-live (no separate frontend call) and finalizes when the stream ends. + pub show_id: Option, } +/// Grace period after a live WS drops before the recording is finalized. +/// A reconnect within this window resumes the same archive instead of starting +/// a new one — so a flaky connection doesn't fragment the recording. +const FINALIZE_GRACE: std::time::Duration = std::time::Duration::from_secs(30); + /// WebSocket upgrade handler for streaming. /// /// Authentication is done via session cookie. @@ -73,16 +81,81 @@ pub async fn stream_ws_handler( } } + let show_id = query.show_id; + // Upgrade to WebSocket - Ok(ws.on_upgrade(move |socket| handle_stream_socket(socket, state, stream_state, username))) + Ok(ws.on_upgrade(move |socket| { + handle_stream_socket(socket, state, stream_state, username, show_id) + })) +} + +/// Cancel a pending grace-period finalize, if one is scheduled. +/// +/// Called on (re)connect and before an explicit finalize so a transient drop +/// followed by a reconnect keeps appending to the same archive. +async fn cancel_pending_finalize(state: &Arc) { + let mut guard = state.recording_finalizer.lock().await; + if let Some(handle) = guard.take() { + handle.abort(); + tracing::debug!("Cancelled pending recording finalize (reconnect or explicit stop)"); + } +} + +/// Schedule a deferred finalize after [`FINALIZE_GRACE`]. If the stream becomes +/// active again (reconnect) before it fires — or the cancel handle is aborted — +/// the recording is left running and continues into the same archive. +async fn schedule_grace_finalize(state: Arc, show_id: i64) { + let task_state = state.clone(); + let task = tokio::spawn(async move { + tokio::time::sleep(FINALIZE_GRACE).await; + + // A reconnect would have made the stream active again. + if task_state.stream_state.lock().await.is_active() { + tracing::info!( + "Grace finalize skipped for show {}: stream is live again", + show_id + ); + return; + } + // Only finalize if we're still recording the same show. + if task_state.recording_manager.lock().await.current_show_id() != Some(show_id) { + return; + } + + tracing::info!( + "Grace period elapsed with no reconnect; finalizing recording for show {}", + show_id + ); + match crate::handlers::recording::finalize_and_upload(&task_state).await { + Ok(Some(r)) => tracing::info!( + "Auto-finalized recording for show {} (version {}, incomplete={})", + r.show_id, + r.version, + r.incomplete + ), + Ok(None) => {} + Err(e) => tracing::error!("Grace finalize failed for show {}: {}", show_id, e), + } + }); + + let mut guard = state.recording_finalizer.lock().await; + if let Some(old) = guard.replace(task.abort_handle()) { + old.abort(); + } } /// Handle the WebSocket connection for streaming. +/// +/// `show_id` (when present) drives automatic recording: it starts when the +/// stream goes live and is finalized when the stream truly ends. A transient +/// disconnect defers finalize for a grace period so a reconnect resumes the +/// same archive rather than fragmenting it. async fn handle_stream_socket( socket: WebSocket, state: Arc, stream_state: SharedStreamState, username: String, + show_id: Option, ) { let (mut sender, mut receiver) = socket.split(); @@ -103,6 +176,17 @@ async fn handle_stream_socket( } } + // Auto-record on go-live. A reconnect cancels any pending finalize and + // resumes the existing recording (ensure_recording_started is a no-op when + // already recording this show). A recording failure here must not kill the + // live broadcast. + if let Some(sid) = show_id { + cancel_pending_finalize(&state).await; + if let Err(e) = crate::handlers::recording::ensure_recording_started(&state, sid).await { + tracing::error!("Failed to auto-start recording for show {}: {}", sid, e); + } + } + // Send confirmation if let Err(e) = sender.send(Message::Text("connected".into())).await { tracing::error!("Failed to send connected message: {}", e); @@ -116,7 +200,9 @@ async fn handle_stream_socket( // Notify admin via Telegram (fire-and-forget) crate::telegram_notify::notify_stream_start(&state, &username); - // Process incoming messages (audio chunks) + // Process incoming messages (audio chunks). `explicit_stop` distinguishes a + // deliberate end (finalize immediately) from a network drop (grace period). + let mut explicit_stop = false; while let Some(msg) = receiver.next().await { match msg { Ok(Message::Binary(data)) => { @@ -133,6 +219,7 @@ async fn handle_stream_socket( // Handle control messages if text.as_str() == "stop" { tracing::info!("Received stop command from '{}'", username); + explicit_stop = true; break; } } @@ -153,7 +240,7 @@ async fn handle_stream_socket( } } - // Stop the stream on disconnect + // Stop the FFmpeg stream on disconnect. { let mut stream = stream_state.lock().await; if let Err(e) = stream.stop_stream().await { @@ -161,6 +248,33 @@ async fn handle_stream_socket( } } + // Decide the recording's fate, decoupled from the browser tab: + // - explicit stop → finalize + upload now. + // - network drop → defer finalize so a reconnect can resume the archive. + if explicit_stop { + cancel_pending_finalize(&state).await; + match crate::handlers::recording::finalize_and_upload(&state).await { + Ok(Some(r)) => tracing::info!( + "Finalized recording for show {} on explicit stop (version {}, incomplete={})", + r.show_id, + r.version, + r.incomplete + ), + Ok(None) => {} + Err(e) => tracing::error!("Finalize on explicit stop failed: {}", e), + } + } else { + let active_show = state.recording_manager.lock().await.current_show_id(); + if let Some(sid) = active_show { + tracing::info!( + "Stream for show {} dropped; deferring finalize for {}s in case of reconnect", + sid, + FINALIZE_GRACE.as_secs() + ); + schedule_grace_finalize(state.clone(), sid).await; + } + } + tracing::info!("Stream ended for user '{}'", username); // Notify admin via Telegram (fire-and-forget) @@ -201,15 +315,29 @@ pub async fn stream_stop( return Ok((StatusCode::UNAUTHORIZED, "Not authenticated").into_response()); } - let mut stream = stream_state.lock().await; - if stream.is_active() { - if let Err(e) = stream.stop_stream().await { - tracing::error!("Failed to stop stream: {}", e); - return Ok(( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to stop: {}", e), - ) - .into_response()); + let was_active = { + let mut stream = stream_state.lock().await; + if stream.is_active() { + if let Err(e) = stream.stop_stream().await { + tracing::error!("Failed to stop stream: {}", e); + return Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to stop: {}", e), + ) + .into_response()); + } + true + } else { + false + } + }; + + if was_active { + // Admin stop is a deliberate end: finalize the recording immediately + // rather than waiting for the grace period. + cancel_pending_finalize(&state).await; + if let Err(e) = crate::handlers::recording::finalize_and_upload(&state).await { + tracing::error!("Finalize on admin stop failed: {}", e); } Ok( axum::Json(serde_json::json!({"success": true, "message": "Stream stopped"})) diff --git a/backend/src/main.rs b/backend/src/main.rs index 31d1630..9ac2644 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -50,6 +50,9 @@ pub type PendingShowNotifications = /// Active Telegram edit sessions keyed by chat_id (only one edit at a time per chat) pub type TelegramEditSessions = Arc>>; +/// Handle to a pending grace-period recording-finalize task, if one is scheduled. +pub type PendingFinalizer = Arc>>; + pub struct AppState { pub db: sqlx::SqlitePool, pub config: Config, @@ -57,6 +60,10 @@ pub struct AppState { pub stream_state: SharedStreamState, /// Recording session manager for show recordings pub recording_manager: SharedRecordingManager, + /// Handle to a pending grace-period finalize task (set when a live stream + /// drops without an explicit stop). A reconnect aborts it so a flaky + /// connection doesn't fragment the archive. See `stream_ws`. + pub recording_finalizer: PendingFinalizer, /// Debounce tracker for show cover regeneration (show_id -> last_request_time) pub cover_debounce: CoverDebounceMap, /// Cached default cover image (4 black tiles with UN/HEARD branding) @@ -245,6 +252,7 @@ async fn main() -> anyhow::Result<()> { s3_client, stream_state, recording_manager, + recording_finalizer: Arc::new(tokio::sync::Mutex::new(None)), cover_debounce, default_cover: tokio::sync::OnceCell::new(), telegram_bot, diff --git a/backend/src/stream_bridge.rs b/backend/src/stream_bridge.rs index 785ef77..dde55f4 100644 --- a/backend/src/stream_bridge.rs +++ b/backend/src/stream_bridge.rs @@ -22,6 +22,10 @@ pub struct StreamState { recording_file: Option, /// Path to the current recording file (for status reporting). recording_path: Option, + /// Set when a recording-file write fails (e.g. disk full). The recording + /// is abandoned (file handle dropped) but the live stream keeps running. + /// Surfaced via [`StreamStatus`] so the failure is never silently swallowed. + recording_failed: Option, } impl Default for StreamState { @@ -38,6 +42,7 @@ impl StreamState { ffmpeg_handle: None, recording_file: None, recording_path: None, + recording_failed: None, } } @@ -52,6 +57,12 @@ impl StreamState { self.recording_file.is_some() } + /// Returns the recording write-failure message, if a tee write has failed + /// since recording started. Used to mark the archive as incomplete. + pub fn recording_failure(&self) -> Option<&str> { + self.recording_failed.as_deref() + } + /// Start streaming for the given user to the specified RTMP destination. /// /// # Arguments @@ -175,11 +186,22 @@ impl StreamState { return Err(StreamError::NotStreaming); } - // Tee to recording file if active (non-fatal errors) + // Tee to recording file if active. A write failure here (e.g. disk full) + // must NOT silently corrupt the archive: surface it as a recording-failed + // status + error log, drop the file handle so we stop appending to a + // half-written file, and keep the live stream running. if let Some(ref mut file) = self.recording_file { if let Err(e) = file.write_all(data).await { - tracing::warn!("Failed to write to recording file: {}", e); - // Don't fail the stream, just log the error + let msg = e.to_string(); + tracing::error!( + "Recording write failed (archive will be incomplete): {} — path={:?}", + msg, + self.recording_path + ); + self.recording_failed = Some(msg); + // Stop teeing — the file is now unreliable. The live stream + // (FFmpeg) is unaffected and continues. + self.recording_file = None; } } @@ -215,6 +237,7 @@ impl StreamState { self.recording_file = Some(file); self.recording_path = Some(path); + self.recording_failed = None; Ok(()) } @@ -249,6 +272,7 @@ impl StreamState { user: self.current_user.clone(), recording: self.is_recording(), recording_path: self.recording_path.clone(), + recording_failed: self.recording_failed.clone(), } } } @@ -374,6 +398,10 @@ pub struct StreamStatus { pub recording: bool, #[serde(skip_serializing_if = "Option::is_none")] pub recording_path: Option, + /// Non-null when a recording write failed mid-stream; the archive for this + /// session is incomplete. Stays set until the next recording starts. + #[serde(skip_serializing_if = "Option::is_none")] + pub recording_failed: Option, } /// Errors that can occur during streaming. diff --git a/frontend/src/admin/composables/useStreamSocket.ts b/frontend/src/admin/composables/useStreamSocket.ts index beea247..4729f08 100644 --- a/frontend/src/admin/composables/useStreamSocket.ts +++ b/frontend/src/admin/composables/useStreamSocket.ts @@ -17,6 +17,8 @@ const reconnectAttempts = ref(0); let socket: WebSocket | null = null; let reconnectTimeout: ReturnType | null = null; +// Remembered across reconnects so the backend keeps auto-recording the same show. +let currentShowId: number | null = null; let currentCallbacks: { onConnected?: () => void; onDisconnected?: () => void; @@ -40,18 +42,27 @@ export function useStreamSocket(options: UseStreamSocketOptions = {}) { // Update callbacks so the currently-mounted component receives events currentCallbacks = { onConnected, onDisconnected, onError, onLive }; - function connect(force = false): Promise { + function connect(force = false, showId?: number): Promise { return new Promise((resolve, reject) => { if (socket && socket.readyState === WebSocket.OPEN) { resolve(); return; } + // Remember the show so reconnects re-send it; backend keys recording on it. + if (showId != null) { + currentShowId = showId; + } + error.value = null; state.value = 'connecting'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/stream${force ? '?force=true' : ''}`; + const params = new URLSearchParams(); + if (force) params.set('force', 'true'); + if (currentShowId != null) params.set('show_id', String(currentShowId)); + const qs = params.toString(); + const wsUrl = `${protocol}//${window.location.host}/ws/stream${qs ? `?${qs}` : ''}`; socket = new WebSocket(wsUrl); socket.binaryType = 'arraybuffer'; @@ -137,6 +148,7 @@ export function useStreamSocket(options: UseStreamSocketOptions = {}) { socket = null; } + currentShowId = null; reconnectAttempts.value = 0; state.value = 'disconnected'; error.value = null; @@ -158,6 +170,7 @@ export function useStreamSocket(options: UseStreamSocketOptions = {}) { socket = null; } + currentShowId = null; reconnectAttempts.value = 0; state.value = 'disconnected'; error.value = null; diff --git a/frontend/src/admin/pages/flow/FlowOnAir.vue b/frontend/src/admin/pages/flow/FlowOnAir.vue index af9be62..9e38926 100644 --- a/frontend/src/admin/pages/flow/FlowOnAir.vue +++ b/frontend/src/admin/pages/flow/FlowOnAir.vue @@ -190,18 +190,13 @@ async function handleGoLive() { try { if (isLiveMode.value) { - await streamSocket.connect(); + // Recording now starts automatically on the backend when the stream goes + // live (keyed on show_id), so it survives a dropped tab / WS reconnect. + await streamSocket.connect(false, show.value?.id); if (audioCapture) { audioCapture.setOnData((data) => streamSocket.send(data)); audioCapture.startRecording(); } - if (flow.recordStream.value && show.value?.id) { - try { - await recordingApi.start(show.value.id); - } catch (err) { - console.warn('[FlowOnAir] Failed to start recording:', err); - } - } // Navigation happens via onLive callback } else { if (!show.value?.id) throw new Error('No show selected'); diff --git a/frontend/src/admin/pages/flow/FlowWaiting.vue b/frontend/src/admin/pages/flow/FlowWaiting.vue index b4dc57b..e4cc8fc 100644 --- a/frontend/src/admin/pages/flow/FlowWaiting.vue +++ b/frontend/src/admin/pages/flow/FlowWaiting.vue @@ -2,7 +2,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'; import { useRouter } from 'vue-router'; import { useHostFlow, useAudioCapture, useStreamSocket } from '@admin/composables'; -import { recordingApi, hostFlowApi } from '@admin/api'; +import { hostFlowApi } from '@admin/api'; const router = useRouter(); const flow = useHostFlow(); @@ -184,8 +184,10 @@ async function handleGoLive() { try { if (isLive.value) { - // Live mode: connect WebSocket, wire audio data, navigate - await streamSocket.connect(); + // Live mode: connect WebSocket, wire audio data, navigate. + // Recording auto-starts on the backend (keyed on show_id) so it survives + // a dropped tab / WS reconnect — no separate start call needed. + await streamSocket.connect(false, show.value?.id); // Wire audioCapture data → streamSocket using singleton's setOnData // Same pattern as StreamPage.vue (commit 07f39e4) @@ -194,16 +196,6 @@ async function handleGoLive() { audioCapture.startRecording(); } - // Start recording if enabled - if (flow.recordStream.value && show.value?.id) { - try { - await recordingApi.start(show.value.id); - } catch (err) { - console.warn('[FlowWaiting] Failed to start recording:', err); - // Don't block going live if recording fails to start - } - } - // Navigation happens via onLive callback when server confirms } else { // Upload mode: tell the backend to start streaming the prerecorded file