379 lines
13 KiB
Rust
379 lines
13 KiB
Rust
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 { .. });
|
|
}
|
|
}
|