streamtop 0.3.0

HLS/DASH/IPTV stream diagnostics TUI and CLI
Documentation
//! Headless summary: poll briefly, print PASS/FAIL, set exit code.

use std::process::ExitCode;
use std::time::Duration;

use color_eyre::eyre::Result;
use crossterm::style::{Color, ResetColor, SetForegroundColor};
use serde::Serialize;
use tokio::sync::mpsc;
use tokio::time::{timeout, Instant};

use crate::engine::ManifestPoller;
use crate::models::{
    CdnStats, DiagCategory, DiagSeverity, HealthReport, LatencyState, StreamEvent, StreamStatus,
    StreamStatusKind,
};
use crate::ui::app::SessionOpts;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SummaryFormat {
    Text,
    Json,
}

#[derive(Debug, Serialize)]
struct SummaryJson {
    verdict: &'static str,
    ok: bool,
    health_score: u8,
    health_label: String,
    status: &'static str,
    latency: String,
    cdn: String,
    ttfb_ms: Option<u64>,
    last_http_status: Option<u16>,
    origin_stalls: u32,
    critical_rfc_errors: u32,
    url: String,
    errors: u32,
    saw_segment: bool,
}

pub async fn run_summary(
    url: String,
    session: SessionOpts,
    timeout_secs: u64,
    format: SummaryFormat,
) -> Result<ExitCode> {
    let (tx, mut rx) = mpsc::unbounded_channel();
    let mut poller = ManifestPoller::new(
        url.clone(),
        session.headers.clone(),
        session.user_agent.clone(),
        session.interval_ms,
        session.probe_headers,
        tx,
    )?;
    if let Some(hook_url) = session.webhook_url.clone() {
        if let Ok(alerts) = crate::engine::webhook::AlertKind::parse_list(&session.alert_on) {
            let (hook_tx, hook_rx) = mpsc::unbounded_channel();
            poller = poller.with_webhook_tx(hook_tx);
            crate::engine::webhook::spawn_webhook_listener(
                crate::engine::webhook::WebhookConfig {
                    url: hook_url,
                    alerts,
                },
                hook_rx,
                url.clone(),
            );
        }
    }
    let handle = tokio::spawn(async move {
        poller.run().await;
    });

    let mut health = HealthReport::perfect();
    let mut status = StreamStatus::live("probing…");
    let mut latency = LatencyState::Unknown;
    let mut cdn = CdnStats::default();
    let mut last_ttfb: Option<u64> = None;
    let mut last_http_status: Option<u16> = None;
    let mut errors = 0u32;
    let mut origin_stalls = 0u32;
    let mut critical_rfc_errors = 0u32;
    let mut saw_segment = false;

    let deadline = Instant::now() + Duration::from_secs(timeout_secs.max(1));
    while Instant::now() < deadline {
        let left = deadline.saturating_duration_since(Instant::now());
        match timeout(left, rx.recv()).await {
            Ok(Some(ev)) => match ev {
                StreamEvent::Health(h) => health = h,
                StreamEvent::Status(s) => status = s,
                StreamEvent::Latency(l) => latency = l,
                StreamEvent::CdnStats(c) => cdn = c,
                StreamEvent::Segment(s) => {
                    saw_segment = true;
                    last_ttfb = Some(s.ttfb_ms);
                    if s.http_status > 0 {
                        last_http_status = Some(s.http_status);
                    }
                }
                StreamEvent::Finding(f) => {
                    if f.category == DiagCategory::Stalling {
                        origin_stalls = origin_stalls.saturating_add(1);
                    }
                    if f.category == DiagCategory::Rfc && f.severity == DiagSeverity::Error {
                        critical_rfc_errors = critical_rfc_errors.saturating_add(1);
                    }
                }
                StreamEvent::Error(_)
                | StreamEvent::Log {
                    level: crate::models::LogLevel::Error,
                    ..
                } => {
                    errors = errors.saturating_add(1);
                }
                StreamEvent::Log {
                    category: DiagCategory::Stalling,
                    ..
                } => {
                    origin_stalls = origin_stalls.saturating_add(1);
                }
                _ => {}
            },
            Ok(None) => break,
            Err(_) => break,
        }
    }

    handle.abort();

    let cdn_badge = cdn
        .last
        .as_ref()
        .map(|c| c.badge())
        .unwrap_or_else(|| "UNKNOWN".into());
    let ttfb = last_ttfb
        .map(|ms| format!("{ms}ms"))
        .unwrap_or_else(|| "".into());

    let http_ok = matches!(last_http_status, Some(200) | Some(206));
    let ok = matches!(status.kind, StreamStatusKind::Live)
        && health.score >= 85
        && critical_rfc_errors == 0
        && origin_stalls == 0
        && http_ok
        && saw_segment;

    let verdict = if ok { "PASS" } else { "FAIL" };
    let status_label = match status.kind {
        StreamStatusKind::Live => "LIVE",
        StreamStatusKind::Degraded => "DEGRADED",
        StreamStatusKind::Error => "ERROR",
    };

    match format {
        SummaryFormat::Json => {
            let payload = SummaryJson {
                verdict,
                ok,
                health_score: health.score,
                health_label: health.label.clone(),
                status: status_label,
                latency: latency.display(),
                cdn: cdn_badge,
                ttfb_ms: last_ttfb,
                last_http_status,
                origin_stalls,
                critical_rfc_errors,
                url: url.clone(),
                errors,
                saw_segment,
            };
            println!("{}", serde_json::to_string(&payload)?);
        }
        SummaryFormat::Text => {
            let color = if ok { Color::Green } else { Color::Red };
            let mut out = std::io::stdout();
            crossterm::execute!(out, SetForegroundColor(color))?;
            print!("{verdict}");
            crossterm::execute!(out, ResetColor)?;
            println!(
                "  SHI={:>3} ({})  status={}  latency={}  CDN={}  TTFB={}  HTTP={}  stalls={}  rfc_err={}  url={}",
                health.score,
                health.label,
                status_label,
                latency.display(),
                cdn_badge,
                ttfb,
                last_http_status
                    .map(|c| c.to_string())
                    .unwrap_or_else(|| "".into()),
                origin_stalls,
                critical_rfc_errors,
                url
            );
        }
    }

    Ok(if ok {
        ExitCode::SUCCESS
    } else {
        ExitCode::from(1)
    })
}