this repo has no description
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}