use anyhow::{anyhow, Context, Result}; use blake3; use clap::{Parser, Subcommand}; use std::fs::{self, File}; use std::io::{Read, Write}; use std::path::PathBuf; #[derive(Parser, Debug)] #[command(name = "every.channel")] #[command(about = "CLI for the every.channel mesh", long_about = None)] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand, Debug)] enum Commands { /// Discover HDHomeRun devices on the network. Discover, /// Fetch channel lineup for a device. Lineup { /// Hostname or IP (e.g. 192.168.1.10 or hdhomerun.local). #[arg(long)] host: Option, /// Device ID (used as .local). #[arg(long)] device_id: Option, }, /// Parse lineup JSON from a file on disk. LineupFile { path: String }, /// Open an HDHomeRun stream and dump MPEG-TS to a file. StreamDump { /// Hostname or IP (e.g. 192.168.1.10). #[arg(long)] host: Option, /// Device ID (used as .local). #[arg(long)] device_id: Option, /// Guide number (e.g. 8.1). #[arg(long)] channel: Option, /// Guide name (e.g. KQED). #[arg(long)] name: Option, /// Optional duration in seconds (if supported by the tuner URL). #[arg(long)] duration: Option, /// Output path for the transport stream. #[arg(long, default_value = "stream.ts")] output: PathBuf, }, /// Chunk an input stream using ffmpeg. Chunk { /// Input URL or file path. input: String, /// Output directory for segments. output_dir: PathBuf, }, /// Probe a media file using ac-ffmpeg. Probe { /// Input file path. input: String, }, /// Analyze TS timing and chunk boundaries. TsSync { /// Input TS file. input: String, /// Chunk duration in ms. #[arg(long, default_value_t = 2000)] chunk_ms: u64, /// Maximum number of events to print. #[arg(long, default_value_t = 50)] max_events: usize, }, /// Re-encode the same input multiple times and compare segment hashes. DeterminismTest { /// Input file path (TS or other supported by ffmpeg). input: String, /// Output directory root (runs will be placed under run-*/). output_dir: PathBuf, /// Number of runs to compare. #[arg(long, default_value_t = 2)] runs: usize, }, /// Open a Linux DVB DVR device and dump MPEG-TS to a file. LinuxDvbDump { /// DVB adapter index. #[arg(long, default_value_t = 0)] adapter: u32, /// DVR device index. #[arg(long, default_value_t = 0)] dvr: u32, /// Optional tune command (repeat for each arg). #[arg(long, allow_hyphen_values = true)] tune_cmd: Vec, /// Optional tune wait (ms). #[arg(long)] tune_wait_ms: Option, /// Output path for the transport stream. #[arg(long, default_value = "linux-dvb.ts")] output: PathBuf, }, } fn main() -> Result<()> { tracing_subscriber::fmt().init(); let cli = Cli::parse(); match cli.command { Commands::Discover => { let devices = ec_hdhomerun::discover()?; println!("{}", serde_json::to_string_pretty(&devices)?); } Commands::Lineup { host, device_id } => { let device = resolve_device(host, device_id)?; let lineup = ec_hdhomerun::fetch_lineup(&device)?; println!("{}", serde_json::to_string_pretty(&lineup)?); } Commands::LineupFile { path } => { let bytes = fs::read(&path)?; let lineup = ec_hdhomerun::lineup_from_json_bytes(&bytes, None)?; println!("{}", serde_json::to_string_pretty(&lineup)?); } Commands::StreamDump { host, device_id, channel, name, duration, output, } => { let device = resolve_device(host, device_id)?; let lineup = ec_hdhomerun::fetch_lineup(&device)?; let entry = if let Some(channel) = channel { ec_hdhomerun::find_lineup_entry_by_number(&lineup, &channel) .or_else(|| ec_hdhomerun::find_lineup_entry_by_name(&lineup, &channel)) .ok_or_else(|| anyhow!("channel not found: {channel}"))? } else if let Some(name) = name { ec_hdhomerun::find_lineup_entry_by_name(&lineup, &name) .ok_or_else(|| anyhow!("channel not found: {name}"))? } else { return Err(anyhow!("--channel or --name required")); }; let mut stream = ec_hdhomerun::open_stream_entry(entry, duration)?; let mut file = File::create(&output) .with_context(|| format!("failed to create {}", output.display()))?; let mut buf = [0u8; 8192]; loop { let read = stream.read(&mut buf)?; if read == 0 { break; } file.write_all(&buf[..read])?; } } Commands::Chunk { input, output_dir } => { let profile = ec_chopper::deterministic_h264_profile(); let config = ec_chopper::ChunkerConfig { output_dir, segment_duration_ms: profile.chunk_duration_ms, segment_template: ec_chopper::ChunkerConfig::default_segment_template(), format: ec_chopper::ChunkFormat::Fmp4, profile, }; let input = if input.starts_with("http://") || input.starts_with("https://") { ec_chopper::ChunkerInput::Url(input) } else { ec_chopper::ChunkerInput::File(PathBuf::from(input)) }; let segmenter = ec_chopper::FfmpegCliSegmenter::default(); let mut process = segmenter.spawn(input, &config)?; let status = process.child.wait()?; if !status.success() { return Err(anyhow!("ffmpeg exited with status {status}")); } let manifest = ec_chopper::collect_segments(&process.output_dir)?; println!("{}", serde_json::to_string_pretty(&manifest)?); } Commands::Probe { input } => { let file = File::open(&input).with_context(|| format!("failed to open {}", input))?; let probes = ec_chopper::probe_read_stream(file)?; println!("{}", serde_json::to_string_pretty(&probes)?); } Commands::TsSync { input, chunk_ms, max_events, } => { let file = File::open(&input).with_context(|| format!("failed to open {}", input))?; let events = ec_chopper::analyze_ts_time(file, chunk_ms, max_events)?; println!("{}", serde_json::to_string_pretty(&events)?); } Commands::DeterminismTest { input, output_dir, runs, } => { if runs < 1 { return Err(anyhow!("runs must be >= 1")); } let profile = ec_chopper::deterministic_h264_profile(); let format = ec_chopper::ChunkFormat::Fmp4; let template = ec_chopper::ChunkerConfig::default_segment_template(); let mut baseline: Option> = None; for run in 0..runs { let run_dir = output_dir.join(format!("run-{}", run + 1)); let _ = fs::remove_dir_all(&run_dir); let config = ec_chopper::ChunkerConfig { output_dir: run_dir.clone(), segment_duration_ms: profile.chunk_duration_ms, segment_template: template.clone(), format: format.clone(), profile: profile.clone(), }; let input_spec = if input.starts_with("http://") || input.starts_with("https://") { ec_chopper::ChunkerInput::Url(input.clone()) } else { ec_chopper::ChunkerInput::File(PathBuf::from(&input)) }; let segmenter = ec_chopper::FfmpegCliSegmenter::default(); let mut process = segmenter.spawn(input_spec, &config)?; let status = process.child.wait()?; if !status.success() { return Err(anyhow!("ffmpeg exited with status {status}")); } let hashes = hash_segments(&process.output_dir)?; match baseline.as_ref() { None => { baseline = Some(hashes); println!( "run {}: baseline ({}) segments", run + 1, baseline.as_ref().unwrap().len() ); } Some(base) => { let mismatches = compare_hashes(base, &hashes); if mismatches > 0 { return Err(anyhow!( "determinism mismatch on run {} ({} mismatches)", run + 1, mismatches )); } println!("run {}: matched baseline", run + 1); } } } } Commands::LinuxDvbDump { adapter, dvr, tune_cmd, tune_wait_ms, output, } => { let config = ec_linux_iptv::LinuxDvbConfig { adapter, frontend: 0, dvr, tune_command: if tune_cmd.is_empty() { None } else { Some(tune_cmd) }, tune_timeout_ms: tune_wait_ms, }; let mut stream = ec_linux_iptv::open_stream(&config)?; let mut file = File::create(&output) .with_context(|| format!("failed to create {}", output.display()))?; let mut buf = [0u8; 8192]; loop { let read = stream.read(&mut buf)?; if read == 0 { break; } file.write_all(&buf[..read])?; } } } Ok(()) } fn hash_segments(output_dir: &PathBuf) -> Result> { let manifest = ec_chopper::collect_segments(output_dir)?; let mut hashes = Vec::new(); for segment in manifest.segments { let bytes = fs::read(&segment.path) .with_context(|| format!("failed to read {}", segment.path.display()))?; let hash = blake3::hash(&bytes); hashes.push(hash.to_hex().to_string()); } Ok(hashes) } fn compare_hashes(base: &[String], candidate: &[String]) -> usize { let mut mismatches = 0usize; let max_len = base.len().max(candidate.len()); for idx in 0..max_len { let base_hash = base.get(idx); let candidate_hash = candidate.get(idx); if base_hash != candidate_hash { mismatches += 1; } } mismatches } fn resolve_device( host: Option, device_id: Option, ) -> Result { if let Some(host) = host { ec_hdhomerun::discover_from_host(&host) } else if let Some(device_id) = device_id { let host = format!("{device_id}.local"); ec_hdhomerun::discover_from_host(&host) } else { let mut devices = ec_hdhomerun::discover()?; devices .pop() .ok_or_else(|| anyhow!("no HDHomeRun devices found")) } } #[cfg(test)] mod tests { use super::*; use clap::Parser; #[test] fn clap_parses_common_subcommands() { let cli = Cli::try_parse_from(["every.channel", "discover"]).unwrap(); matches!(cli.command, Commands::Discover); let cli = Cli::try_parse_from([ "every.channel", "ts-sync", "input.ts", "--chunk-ms", "1000", "--max-events", "5", ]) .unwrap(); matches!(cli.command, Commands::TsSync { .. }); let cli = Cli::try_parse_from([ "every.channel", "linux-dvb-dump", "--adapter", "0", "--dvr", "0", "--tune-cmd", "dvbv5-zap", "--tune-cmd", "-r", "--tune-cmd", "KQED", ]) .unwrap(); matches!(cli.command, Commands::LinuxDvbDump { .. }); } }