every.channel: sanitized baseline

This commit is contained in:
every.channel 2026-02-15 16:17:27 -05:00
commit 897e556bea
No known key found for this signature in database
258 changed files with 74298 additions and 0 deletions

17
crates/ec-cli/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "ec-cli"
version = "0.0.0"
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
blake3.workspace = true
clap.workspace = true
ec-chopper = { path = "../ec-chopper" }
ec-core = { path = "../ec-core" }
ec-hdhomerun = { path = "../ec-hdhomerun" }
ec-linux-iptv = { path = "../ec-linux-iptv" }
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

379
crates/ec-cli/src/main.rs Normal file
View file

@ -0,0 +1,379 @@
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<String>,
/// Device ID (used as <deviceid>.local).
#[arg(long)]
device_id: Option<String>,
},
/// 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<String>,
/// Device ID (used as <deviceid>.local).
#[arg(long)]
device_id: Option<String>,
/// Guide number (e.g. 8.1).
#[arg(long)]
channel: Option<String>,
/// Guide name (e.g. KQED).
#[arg(long)]
name: Option<String>,
/// Optional duration in seconds (if supported by the tuner URL).
#[arg(long)]
duration: Option<u32>,
/// 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<String>,
/// Optional tune wait (ms).
#[arg(long)]
tune_wait_ms: Option<u64>,
/// 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<Vec<String>> = 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<Vec<String>> {
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<String>,
device_id: Option<String>,
) -> Result<ec_hdhomerun::HdhomerunDevice> {
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 { .. });
}
}