this repo has no description
at main 235 lines 6.7 kB view raw
1use std::path::PathBuf; 2use std::time::Duration; 3 4use anyhow::{Context, Result, bail}; 5use clap::Parser; 6use tracing::{info, warn}; 7 8use browser_stream::chromium; 9use browser_stream::cli::{AppConfig, CliArgs}; 10use browser_stream::encoder::{self, EncoderSettings, FfmpegEncoder}; 11use browser_stream::error::RuntimeError; 12use browser_stream::retry::RetryPolicy; 13 14#[derive(Debug, Clone)] 15struct RuntimePaths { 16 ffmpeg: PathBuf, 17 chromium: PathBuf, 18} 19 20#[tokio::main] 21async fn main() -> Result<()> { 22 let args = CliArgs::parse(); 23 init_tracing(args.verbose); 24 25 let config = args.into_config()?; 26 let runtime_paths = resolve_runtime_paths(&config)?; 27 let retry_policy = RetryPolicy::new( 28 config.retries, 29 Duration::from_millis(config.retry_backoff_ms), 30 ); 31 32 run_with_retry(&config, &runtime_paths, &retry_policy).await 33} 34 35async fn run_with_retry( 36 config: &AppConfig, 37 runtime_paths: &RuntimePaths, 38 retry_policy: &RetryPolicy, 39) -> Result<()> { 40 let mut failures = 0_u32; 41 42 loop { 43 let attempt = failures + 1; 44 info!(attempt, "starting stream attempt"); 45 46 let result = run_once(config, runtime_paths).await; 47 48 match result { 49 Ok(()) => return Ok(()), 50 Err(err) => { 51 if is_shutdown_error(&err) { 52 info!("shutdown requested, exiting"); 53 // Force process termination in case any background runtime task/thread 54 // holds the process open after graceful shutdown. 55 std::process::exit(0); 56 } 57 58 failures = failures.saturating_add(1); 59 if !retry_policy.should_retry(failures) { 60 return Err(err.context(format!( 61 "stream failed after {attempt} attempt(s) with {failures} failure(s)" 62 ))); 63 } 64 65 warn!( 66 attempt, 67 failures, 68 backoff_ms = retry_policy.backoff.as_millis(), 69 error = %err, 70 "stream attempt failed; retrying" 71 ); 72 73 tokio::select! { 74 _ = tokio::time::sleep(retry_policy.backoff) => {} 75 _ = tokio::signal::ctrl_c() => { 76 info!("shutdown requested during retry backoff, exiting"); 77 return Ok(()); 78 } 79 } 80 } 81 } 82 } 83} 84 85async fn run_once(config: &AppConfig, runtime_paths: &RuntimePaths) -> Result<()> { 86 let settings = EncoderSettings { 87 width: config.width, 88 height: config.height, 89 fps: config.fps, 90 bitrate_kbps: config.bitrate_kbps, 91 keyint_sec: config.keyint_sec, 92 x264_opts: config.x264_opts.clone(), 93 output: config.output.clone(), 94 include_silent_audio: !config.no_audio, 95 ffmpeg_path: runtime_paths.ffmpeg.clone(), 96 }; 97 98 let mut encoder = FfmpegEncoder::spawn(&settings, config.verbose).await?; 99 100 let stream_result = 101 chromium::stream_browser_to_encoder(config, &runtime_paths.chromium, &mut encoder).await; 102 103 match stream_result { 104 Ok(()) => { 105 let status = encoder.wait_for_exit().await?; 106 if !status.success() { 107 bail!("ffmpeg exited with status {status}"); 108 } 109 Ok(()) 110 } 111 Err(err) => { 112 encoder.kill_and_wait().await; 113 Err(err) 114 } 115 } 116} 117 118fn resolve_runtime_paths(config: &AppConfig) -> Result<RuntimePaths> { 119 let current_exe = 120 std::env::current_exe().context("failed to determine current executable path")?; 121 let exe_dir = current_exe 122 .parent() 123 .context("failed to determine current executable directory")?; 124 125 let ffmpeg_path = resolve_ffmpeg_path(config.ffmpeg_path.clone(), exe_dir)?; 126 127 let chromium_path = resolve_binary_path( 128 config.chromium_path.clone(), 129 chromium::default_chromium_sidecar_path(exe_dir), 130 "headless_shell", 131 )?; 132 133 Ok(RuntimePaths { 134 ffmpeg: ffmpeg_path, 135 chromium: chromium_path, 136 }) 137} 138 139fn resolve_binary_path( 140 override_path: Option<PathBuf>, 141 default_path: PathBuf, 142 name: &'static str, 143) -> Result<PathBuf> { 144 let candidate = override_path.unwrap_or(default_path); 145 146 if candidate.is_file() { 147 return Ok(candidate); 148 } 149 150 Err(RuntimeError::MissingSidecar { 151 name, 152 path: candidate, 153 } 154 .into()) 155} 156 157fn resolve_ffmpeg_path( 158 override_path: Option<PathBuf>, 159 exe_dir: &std::path::Path, 160) -> Result<PathBuf> { 161 if let Some(path) = override_path { 162 return resolve_binary_path(Some(path), PathBuf::new(), "ffmpeg"); 163 } 164 165 let sidecar = encoder::default_ffmpeg_sidecar_path(exe_dir); 166 let system = find_in_path(encoder::ffmpeg_executable_name()); 167 168 if cfg!(target_os = "macos") { 169 if let Some(system_path) = system { 170 info!( 171 ffmpeg = %system_path.display(), 172 "using system ffmpeg on macOS (preferred over sidecar)" 173 ); 174 return Ok(system_path); 175 } 176 } 177 178 if sidecar.is_file() { 179 return Ok(sidecar); 180 } 181 182 if let Some(system_path) = find_in_path(encoder::ffmpeg_executable_name()) { 183 info!( 184 ffmpeg = %system_path.display(), 185 "using system ffmpeg from PATH" 186 ); 187 return Ok(system_path); 188 } 189 190 Err(RuntimeError::MissingSidecar { 191 name: "ffmpeg", 192 path: sidecar, 193 } 194 .into()) 195} 196 197fn find_in_path(executable_name: &str) -> Option<PathBuf> { 198 let path_var = std::env::var_os("PATH")?; 199 let candidates = std::env::split_paths(&path_var); 200 201 for dir in candidates { 202 let direct = dir.join(executable_name); 203 if direct.is_file() { 204 return Some(direct); 205 } 206 207 if cfg!(target_os = "windows") { 208 let exe = dir.join(format!("{executable_name}.exe")); 209 if exe.is_file() { 210 return Some(exe); 211 } 212 } 213 } 214 215 None 216} 217 218fn is_shutdown_error(err: &anyhow::Error) -> bool { 219 err.downcast_ref::<RuntimeError>() 220 .is_some_and(|runtime| matches!(runtime, RuntimeError::ShutdownRequested)) 221} 222 223fn init_tracing(verbose: bool) { 224 let filter = if verbose { 225 tracing_subscriber::EnvFilter::new("info,browser_stream=debug,ffmpeg=info") 226 } else { 227 tracing_subscriber::EnvFilter::try_from_default_env() 228 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")) 229 }; 230 231 let _ = tracing_subscriber::fmt() 232 .with_env_filter(filter) 233 .with_target(true) 234 .try_init(); 235}