From d6fec92bc3fb9abb5c2a05aea1b8e548b0e17a6a Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:00:50 +0000 Subject: [PATCH] fix: fall back to main stream when Linux substream playback fails --- client/core/src/bundle.rs | 40 +++++++++++ client/src/platform/linux/ui.rs | 124 +++++++++++++++++++++++++++++--- 2 files changed, 155 insertions(+), 9 deletions(-) diff --git a/client/core/src/bundle.rs b/client/core/src/bundle.rs index fb5690d..2dd84ec 100644 --- a/client/core/src/bundle.rs +++ b/client/core/src/bundle.rs @@ -336,6 +336,21 @@ pub struct BundleGpioBinding { } impl BundleCamera { + /// A layout visit gets one main-stream fallback attempt, on an explicit + /// error or ten seconds without video. Never oscillate after main fails. + pub fn should_try_main(&self, badge: char, attempted: bool, failed: bool, silent_ms: u64) -> bool { + badge == 'S' && !attempted && (failed || silent_ms >= 10_000) + && self.main_fallback_uri().is_some() + } + + /// Only fall back when discovery provides distinct main and sub URLs. + pub fn main_fallback_uri(&self) -> Option<&str> { + let sub = self.streams.iter().find(|stream| stream.role == "sub")?; + let main = self.streams.iter().find(|stream| stream.role == "main")?; + (!main.rtsp_uri.is_empty() && main.rtsp_uri != sub.rtsp_uri) + .then_some(main.rtsp_uri.as_str()) + } + /// Pick stream URI + role tag for this camera given selector and cell area fraction. /// Heuristic: when selector=auto, cell ≥20% of grid → main, else sub. /// Returns (uri, role_letter) where role_letter is 'M' or 'S' (or empty if single stream). @@ -386,6 +401,31 @@ impl BundleCamera { mod tests { use super::*; + #[test] + fn substream_fallback_requires_a_distinct_main_stream() { + let mut camera: BundleCamera = serde_json::from_value(serde_json::json!({ + "id": "camera", "name": "Camera", "type": "onvif", "stream_policy": "auto", + "streams": [ + {"id": "main", "name": "Main", "role": "main", "rtsp_uri": "rtsp://nvr/main"}, + {"id": "sub", "name": "Sub", "role": "sub", "rtsp_uri": "rtsp://nvr/sub"} + ] + })).unwrap(); + assert_eq!(camera.pick_stream(None, 0.1).unwrap().1, 'S'); + assert_eq!(camera.main_fallback_uri(), Some("rtsp://nvr/main")); + assert!(!camera.should_try_main('S', false, false, 9_999)); + assert!(camera.should_try_main('S', false, true, 0), "404 falls back immediately"); + assert!(camera.should_try_main('S', false, false, 10_000), "silent substream times out"); + assert!(!camera.should_try_main('M', false, true, 30_000), "main failure cannot bounce back"); + assert!(!camera.should_try_main('S', true, true, 30_000), "no repeat within a layout visit"); + assert!(camera.should_try_main('S', false, true, 0), "new visit permits another attempt"); + camera.streams[0].rtsp_uri = "rtsp://nvr/sub".into(); + assert_eq!(camera.main_fallback_uri(), None, "do not retry the same failing URL"); + camera.streams[0].rtsp_uri.clear(); + assert_eq!(camera.main_fallback_uri(), None); + camera.streams.remove(0); + assert_eq!(camera.main_fallback_uri(), None, "no invented main URL"); + } + #[test] fn normalizes_legacy_display_and_flexible_ids() { let bundle: KioskBundle = serde_json::from_value(serde_json::json!({ diff --git a/client/src/platform/linux/ui.rs b/client/src/platform/linux/ui.rs index 3af3d51..baa218d 100644 --- a/client/src/platform/linux/ui.rs +++ b/client/src/platform/linux/ui.rs @@ -145,6 +145,11 @@ struct WebEventMeta { } thread_local! { + // Fallback decisions belong to a display's current layout visit, not the + // camera configuration. Other displays may continue using the substream. + static MAIN_FALLBACKS: RefCell> + = RefCell::new(std::collections::HashSet::new()); + /// (camera_id, badge) → PipelineEntry. Pool shared across all displays. /// State machine: see WarmthState. Entries dropped when state goes Cold. static WARM_CAMERAS: RefCell> @@ -1782,6 +1787,32 @@ fn render_layout_inner(display_id: &str, layout_id: &str, preserve_override: boo } }); + if layout_changed { + let retry: Vec = MAIN_FALLBACKS.with(|fallbacks| { + let mut fallbacks = fallbacks.borrow_mut(); + let retry = fallbacks.iter().filter(|(id, _)| id == display_id) + .map(|(_, camera)| camera.clone()).collect(); + fallbacks.retain(|(id, _)| id != display_id); + retry + }); + // Start a fresh substream attempt; do not reuse its previous error. + WARM_CAMERAS.with(|pool| { + for camera in retry { + if let Some(entry) = pool.borrow().get(&(camera.clone(), 'S')) { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default().as_millis() as u64; + if entry.stream_status.load(Ordering::Relaxed) != pipeline::STATUS_OK + || now.saturating_sub(entry.last_buffer_at.load(Ordering::Relaxed)) >= 10_000 + { + info!("camera {camera}: retrying substream after layout change"); + pipeline::restart(&entry.pipeline, &entry.last_buffer_at, + &entry.stream_status, &entry.pipeline_stats); + } + } + } + }); + } + if DISPLAYS.with(|ds| ds.borrow().get(display_id).is_some_and(|st| st.is_asleep)) { recompute_global_state(); return; @@ -1858,7 +1889,10 @@ fn render_layout_inner(display_id: &str, layout_id: &str, preserve_override: boo // Ensure preloaded cameras have pipelines even if not visible. for cam_id in &layout.preload_camera_ids { if let Some(cam) = cam_map.get(cam_id.as_str()) { - ensure_warm(cam_id, cam, None, 0.0); + let selector = MAIN_FALLBACKS.with(|f| { + f.borrow().contains(&(display_id.to_string(), cam_id.clone())) + }).then_some("main"); + ensure_warm(cam_id, cam, selector, 0.0); } } @@ -1885,8 +1919,13 @@ fn render_layout_inner(display_id: &str, layout_id: &str, preserve_override: boo if let Some(cam_id) = cell.camera_id.as_ref() { if let Some(cam) = cam_map.get(cam_id.as_str()) { let area = (cell.col_span * cell.row_span) as f32 / total_area; - if let Some((paintable, badge, stream_status)) = - ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area) + let preferred = cam.pick_stream(cell.stream_selector.as_deref(), area); + let fallback_key = (display_id.to_string(), cam_id.clone()); + let remembered = cam.main_fallback_uri().is_some() && preferred.as_ref().is_some_and(|(_, badge)| *badge == 'S') + && MAIN_FALLBACKS.with(|f| f.borrow().contains(&fallback_key)); + let selector = if remembered { Some("main") } else { cell.stream_selector.as_deref() }; + if let Some((paintable, badge, mut stream_status)) = + ensure_warm(cam_id, cam, selector, area) { let picture = Picture::for_paintable(&paintable); picture.set_content_fit(match cell.fit.as_str() { @@ -1942,13 +1981,15 @@ fn render_layout_inner(display_id: &str, layout_id: &str, preserve_override: boo badge_box.set_margin_start(4); badge_box.set_margin_top(4); + let stream_label = Label::new(None); if badge == 'M' || badge == 'S' { - let label = Label::new(Some(&badge.to_string())); + let label = &stream_label; + label.set_label(&badge.to_string()); add_css( - &label, + label, "label { background: rgba(0,0,0,0.6); color: #fff; font-size: 11px; font-weight: 600; padding: 2px 6px; border-radius: 4px; min-width: 14px; }", ); - badge_box.append(&label); + badge_box.append(label); } let status_label = Label::new(None); @@ -1963,12 +2004,55 @@ fn render_layout_inner(display_id: &str, layout_id: &str, preserve_override: boo // Poll stream status every 1s; stop when label is dropped let status_weak = status_label.downgrade(); + let picture_weak = picture.downgrade(); + let badge_weak = stream_label.downgrade(); + let camera = (**cam).clone(); + let camera_id = cam_id.clone(); + let mut current_badge = badge; + let mut fallback_attempted = remembered; gtk::glib::timeout_add_local(Duration::from_secs(1), move || { let Some(lbl) = status_weak.upgrade() else { return gtk::glib::ControlFlow::Break; }; let s = stream_status.load(Ordering::Relaxed); - match s { + let silent_ms = WARM_CAMERAS.with(|pool| { + pool.borrow().get(&(camera_id.clone(), current_badge)) + .map(|entry| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default().as_millis() as u64; + now.saturating_sub(entry.last_buffer_at.load(Ordering::Relaxed)) + }).unwrap_or(0) + }); + if camera.should_try_main(current_badge, fallback_attempted, + s == pipeline::STATUS_ERROR, silent_ms) + { + warn!("camera {} on display {}: substream {} — trying main for this layout visit", + camera_id, fallback_key.0, if s == pipeline::STATUS_ERROR { "failed" } else { "stalled" }); + if let Some((paintable, badge, status)) = ensure_warm(&camera_id, &camera, Some("main"), area) { + fallback_attempted = true; + MAIN_FALLBACKS.with(|f| { f.borrow_mut().insert(fallback_key.clone()); }); + WARM_CAMERAS.with(|pool| { + if let Some(entry) = pool.borrow().get(&(camera_id.clone(), badge)) { + if status.load(Ordering::Relaxed) == pipeline::STATUS_ERROR { + pipeline::restart(&entry.pipeline, &entry.last_buffer_at, + &entry.stream_status, &entry.pipeline_stats); + } + } + }); + if let Some(picture) = picture_weak.upgrade() { + picture.set_paintable(Some(&paintable)); + } + if let Some(label) = badge_weak.upgrade() { + label.set_label("M"); + label.set_tooltip_text(Some("Main stream fallback: substream unavailable")); + } + current_badge = badge; + stream_status = status; + recompute_global_state(); + } + } + match stream_status.load(Ordering::Relaxed) { pipeline::STATUS_RESTARTING => { lbl.set_label("↻"); lbl.set_visible(true); @@ -2541,6 +2625,7 @@ fn recompute_global_state() { // layout. Falls back to a "?" badge if pick_stream can't decide (camera // missing or no streams). fn cell_keys( + display_id: &str, layout: &crate::bundle::BundleLayout, cam_map: &HashMap<&str, &crate::bundle::BundleCamera>, out: &mut std::collections::HashSet, @@ -2558,6 +2643,9 @@ fn recompute_global_state() { }; let area = (cell.col_span * cell.row_span) as f32 / total_area; if let Some((_, badge)) = cam.pick_stream(cell.stream_selector.as_deref(), area) { + let badge = if badge == 'S' && MAIN_FALLBACKS.with(|f| { + f.borrow().contains(&(display_id.to_string(), cam_id.clone())) + }) { 'M' } else { badge }; out.insert((cam_id.clone(), badge)); } } @@ -2567,6 +2655,9 @@ fn recompute_global_state() { for cam_id in &layout.preload_camera_ids { if let Some(cam) = cam_map.get(cam_id.as_str()) { if let Some((_, badge)) = cam.pick_stream(None, 0.0) { + let badge = if badge == 'S' && MAIN_FALLBACKS.with(|f| { + f.borrow().contains(&(display_id.to_string(), cam_id.clone())) + }) { 'M' } else { badge }; out.insert((cam_id.clone(), badge)); } } @@ -2591,7 +2682,7 @@ fn recompute_global_state() { let t = if t == 0 { DEFAULT_COOLING_SECS } else { t }; max_cooling_secs = max_cooling_secs.max(t); if !is_asleep { - cell_keys(layout, &cam_map, &mut warm_set); + cell_keys(&bd.id, layout, &cam_map, &mut warm_set); } } } @@ -2600,11 +2691,26 @@ fn recompute_global_state() { } for layout in &bd.layouts { if layout.priority == "hot" { - cell_keys(layout, &cam_map, &mut hot_set); + cell_keys(&bd.id, layout, &cam_map, &mut hot_set); } } } + // A fallback's main pipeline must remain warm even though the layout + // still requests substream. Prune decisions for removed displays/cameras. + MAIN_FALLBACKS.with(|fallbacks| { + let mut fallbacks = fallbacks.borrow_mut(); + fallbacks.retain(|(display, camera)| { + active.iter().any(|(id, layout, _)| id == display && layout.is_some()) + && cam_map.contains_key(camera.as_str()) + }); + for (display, camera) in fallbacks.iter() { + if active.iter().any(|(id, _, asleep)| id == display && !asleep) { + warm_set.insert((camera.clone(), 'M')); + } + } + }); + // Same walk for web/html cells — pool keys are URL / hash(HTML). let mut warm_webs: std::collections::HashSet = std::collections::HashSet::new(); let mut hot_webs: std::collections::HashSet = std::collections::HashSet::new();