every.channel: sanitized baseline
This commit is contained in:
commit
897e556bea
258 changed files with 74298 additions and 0 deletions
17
crates/ec-cli/Cargo.toml
Normal file
17
crates/ec-cli/Cargo.toml
Normal 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
379
crates/ec-cli/src/main.rs
Normal 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 { .. });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue