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

View file

@ -0,0 +1,676 @@
//! HDHomeRun discovery, lineup ingest, and stream scaffolding.
use anyhow::{anyhow, Context, Result};
use ec_core::{Channel, ChannelId, ChannelMetadata, DeviceId};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::Read;
use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
use std::time::{Duration, Instant};
const DISCOVER_UDP_PORT: u16 = 65001;
const TYPE_DISCOVER_REQ: u16 = 0x0002;
const TYPE_DISCOVER_RPY: u16 = 0x0003;
const TAG_DEVICE_TYPE: u8 = 0x01;
const TAG_DEVICE_ID: u8 = 0x02;
const TAG_TUNER_COUNT: u8 = 0x10;
const TAG_DEVICE_AUTH_BIN: u8 = 0x29;
const TAG_BASE_URL: u8 = 0x2A;
const TAG_DEVICE_AUTH_STR: u8 = 0x2B;
const DEVICE_TYPE_TUNER: u32 = 0x00000001;
const DEVICE_ID_WILDCARD: u32 = 0xFFFFFFFF;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceField {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HdhomerunDevice {
pub id: DeviceId,
pub ip: String,
pub tuner_count: u8,
pub lineup_url: Option<String>,
pub discover_url: Option<String>,
pub base_url: Option<String>,
pub device_auth: Option<String>,
pub friendly_name: Option<String>,
pub model_number: Option<String>,
pub firmware_name: Option<String>,
pub firmware_version: Option<String>,
pub device_type: Option<String>,
pub discovery_tags: Vec<DeviceField>,
pub raw_discover_json: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineupEntry {
pub channel: Channel,
pub stream_url: String,
pub tags: Vec<String>,
pub raw: Value,
}
pub struct HdhomerunStream {
pub url: String,
reader: Box<dyn Read + Send>,
}
impl std::fmt::Debug for HdhomerunStream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HdhomerunStream")
.field("url", &self.url)
.finish_non_exhaustive()
}
}
impl Read for HdhomerunStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.reader.read(buf)
}
}
#[derive(Debug, Clone, Deserialize)]
struct DiscoverJson {
#[serde(rename = "DeviceID")]
device_id: Option<String>,
#[serde(rename = "DeviceAuth")]
device_auth: Option<String>,
#[serde(rename = "BaseURL")]
base_url: Option<String>,
#[serde(rename = "LineupURL")]
lineup_url: Option<String>,
#[serde(rename = "DiscoverURL")]
discover_url: Option<String>,
#[serde(rename = "FriendlyName")]
friendly_name: Option<String>,
#[serde(rename = "ModelNumber")]
model_number: Option<String>,
#[serde(rename = "FirmwareName")]
firmware_name: Option<String>,
#[serde(rename = "FirmwareVersion")]
firmware_version: Option<String>,
#[serde(rename = "DeviceType")]
device_type: Option<String>,
#[serde(rename = "TunerCount")]
tuner_count: Option<u8>,
}
#[derive(Debug, Clone, Deserialize)]
struct LineupJsonEntry {
#[serde(rename = "GuideNumber")]
guide_number: Option<String>,
#[serde(rename = "GuideName")]
guide_name: Option<String>,
#[serde(rename = "Tags")]
tags: Option<String>,
#[serde(rename = "URL")]
url: Option<String>,
}
/// Discover devices using UDP broadcast, then hydrate with /discover.json when possible.
pub fn discover() -> Result<Vec<HdhomerunDevice>> {
let mut devices = discover_udp(Duration::from_millis(400))?;
if devices.is_empty() {
if let Ok(device) = discover_from_host("hdhomerun.local") {
devices.push(device);
}
}
Ok(devices)
}
/// Discover a device by hostname or IP using the HTTP discover.json endpoint.
pub fn discover_from_host(host: &str) -> Result<HdhomerunDevice> {
let base_url = format!("http://{host}");
let discover_url = format!("{base_url}/discover.json");
let json = fetch_json(&discover_url)?;
let discover: DiscoverJson = serde_json::from_value(json.clone())
.with_context(|| format!("invalid discover.json from {discover_url}"))?;
let device = HdhomerunDevice {
id: DeviceId(
discover
.device_id
.clone()
.unwrap_or_else(|| "unknown".to_string()),
),
ip: host.to_string(),
tuner_count: discover.tuner_count.unwrap_or(0),
lineup_url: discover.lineup_url.clone(),
discover_url: discover.discover_url.clone().or(Some(discover_url)),
base_url: discover.base_url.clone().or(Some(base_url)),
device_auth: discover.device_auth.clone(),
friendly_name: discover.friendly_name.clone(),
model_number: discover.model_number.clone(),
firmware_name: discover.firmware_name.clone(),
firmware_version: discover.firmware_version.clone(),
device_type: discover.device_type.clone(),
discovery_tags: Vec::new(),
raw_discover_json: Some(json),
};
Ok(device)
}
/// Fetch and normalize lineup information for a device.
pub fn fetch_lineup(device: &HdhomerunDevice) -> Result<Vec<LineupEntry>> {
let lineup_url = resolve_lineup_url(device)?;
let json = fetch_json(&lineup_url)?;
lineup_from_json_value(&json, Some(&device.id))
.with_context(|| format!("invalid lineup.json from {lineup_url}"))
}
/// Parse a lineup.json file already loaded into memory.
pub fn lineup_from_json_bytes(
bytes: &[u8],
device_id: Option<&DeviceId>,
) -> Result<Vec<LineupEntry>> {
let json: Value = serde_json::from_slice(bytes)?;
lineup_from_json_value(&json, device_id)
}
/// Open a raw MPEG-TS stream by channel ID (lineup lookup required).
pub fn open_stream(device: &HdhomerunDevice, channel: &ChannelId) -> Result<HdhomerunStream> {
let lineup = fetch_lineup(device)?;
let entry = lineup
.into_iter()
.find(|entry| entry.channel.id == *channel)
.ok_or_else(|| anyhow!("channel {} not found in lineup", channel.0))?;
open_stream_entry(&entry, None)
}
/// Open a raw MPEG-TS stream from a lineup entry.
pub fn open_stream_entry(
entry: &LineupEntry,
duration_secs: Option<u32>,
) -> Result<HdhomerunStream> {
open_stream_url(&entry.stream_url, duration_secs)
}
/// Open a raw MPEG-TS stream by URL.
pub fn open_stream_url(url: &str, duration_secs: Option<u32>) -> Result<HdhomerunStream> {
let url = if let Some(duration) = duration_secs {
append_query_param(url, "duration", &duration.to_string())
} else {
url.to_string()
};
// Streams can be long-lived. Only apply read timeout when the caller requests
// `duration=...` (useful for tests and short captures).
let mut agent_builder = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(3));
if let Some(duration) = duration_secs {
agent_builder = agent_builder.timeout_read(Duration::from_secs(duration as u64 + 10));
}
let agent = agent_builder.build();
let response = agent
.get(&url)
.call()
.with_context(|| format!("failed to open stream {url}"))?;
if response.status() < 200 || response.status() >= 300 {
return Err(anyhow!(
"stream returned http {} for {}",
response.status(),
url
));
}
Ok(HdhomerunStream {
url,
reader: response.into_reader(),
})
}
pub fn find_lineup_entry_by_number<'a>(
lineup: &'a [LineupEntry],
guide_number: &str,
) -> Option<&'a LineupEntry> {
lineup
.iter()
.find(|entry| entry.channel.number.as_deref() == Some(guide_number))
}
pub fn find_lineup_entry_by_name<'a>(
lineup: &'a [LineupEntry],
guide_name: &str,
) -> Option<&'a LineupEntry> {
lineup.iter().find(|entry| entry.channel.name == guide_name)
}
fn discover_udp(timeout: Duration) -> Result<Vec<HdhomerunDevice>> {
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
socket.set_broadcast(true)?;
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
let packet = build_discover_packet()?;
let broadcast_addr = SocketAddrV4::new(Ipv4Addr::BROADCAST, DISCOVER_UDP_PORT);
socket.send_to(&packet, broadcast_addr)?;
let mut devices = Vec::new();
let start = Instant::now();
let mut buf = [0u8; 2048];
while start.elapsed() < timeout {
match socket.recv_from(&mut buf) {
Ok((len, addr)) => {
if let Ok(device) = parse_discover_response(&buf[..len], addr.ip().to_string()) {
devices.push(device);
}
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => continue,
Err(err) if err.kind() == std::io::ErrorKind::TimedOut => continue,
Err(err) => return Err(err.into()),
}
}
for device in devices.iter_mut() {
if let Ok(json) = try_fetch_discover_json(&device.ip) {
apply_discover_json(device, json);
}
}
Ok(devices)
}
fn build_discover_packet() -> Result<Vec<u8>> {
let mut payload = Vec::new();
payload.extend(tlv(TAG_DEVICE_TYPE, &DEVICE_TYPE_TUNER.to_be_bytes()));
payload.extend(tlv(TAG_DEVICE_ID, &DEVICE_ID_WILDCARD.to_be_bytes()));
let mut packet = Vec::with_capacity(4 + payload.len() + 4);
packet.extend(TYPE_DISCOVER_REQ.to_be_bytes());
packet.extend((payload.len() as u16).to_be_bytes());
packet.extend(payload);
let crc = crc32fast::hash(&packet);
packet.extend(crc.to_le_bytes());
Ok(packet)
}
fn parse_discover_response(bytes: &[u8], ip: String) -> Result<HdhomerunDevice> {
if bytes.len() < 8 {
return Err(anyhow!("discover reply too short"));
}
let packet_type = u16::from_be_bytes([bytes[0], bytes[1]]);
if packet_type != TYPE_DISCOVER_RPY {
return Err(anyhow!("unexpected packet type"));
}
let payload_len = u16::from_be_bytes([bytes[2], bytes[3]]) as usize;
if bytes.len() < 4 + payload_len + 4 {
return Err(anyhow!("truncated discover reply"));
}
let payload = &bytes[4..4 + payload_len];
let expected_crc = u32::from_le_bytes([
bytes[4 + payload_len],
bytes[4 + payload_len + 1],
bytes[4 + payload_len + 2],
bytes[4 + payload_len + 3],
]);
let actual_crc = crc32fast::hash(&bytes[..4 + payload_len]);
if expected_crc != actual_crc {
return Err(anyhow!("bad crc"));
}
let mut cursor = 0usize;
let mut device_id: Option<String> = None;
let mut tuner_count: Option<u8> = None;
let mut base_url: Option<String> = None;
let mut device_auth: Option<String> = None;
let mut tags: Vec<DeviceField> = Vec::new();
while cursor < payload.len() {
let tag = payload[cursor];
cursor += 1;
let (length, consumed) = read_varlen(&payload[cursor..])?;
cursor += consumed;
if cursor + length > payload.len() {
return Err(anyhow!("discover TLV length overflow"));
}
let value = &payload[cursor..cursor + length];
cursor += length;
match tag {
TAG_DEVICE_ID => {
if value.len() == 4 {
let id = u32::from_be_bytes([value[0], value[1], value[2], value[3]]);
device_id = Some(format!("{id:08X}"));
}
}
TAG_TUNER_COUNT => {
if let Some(first) = value.first() {
tuner_count = Some(*first);
}
}
TAG_BASE_URL => {
if let Ok(text) = std::str::from_utf8(value) {
base_url = Some(text.trim_end_matches('\0').to_string());
}
}
TAG_DEVICE_AUTH_STR => {
if let Ok(text) = std::str::from_utf8(value) {
device_auth = Some(text.trim_end_matches('\0').to_string());
}
}
TAG_DEVICE_AUTH_BIN => {
tags.push(DeviceField {
key: "device_auth_bin".to_string(),
value: hex::encode(value),
});
}
TAG_DEVICE_TYPE => {
tags.push(DeviceField {
key: "device_type".to_string(),
value: hex::encode(value),
});
}
other => {
tags.push(DeviceField {
key: format!("tag_{other:02X}"),
value: hex::encode(value),
});
}
}
}
let id = device_id.unwrap_or_else(|| "unknown".to_string());
let device = HdhomerunDevice {
id: DeviceId(id),
ip,
tuner_count: tuner_count.unwrap_or(0),
lineup_url: None,
discover_url: None,
base_url,
device_auth,
friendly_name: None,
model_number: None,
firmware_name: None,
firmware_version: None,
device_type: None,
discovery_tags: tags,
raw_discover_json: None,
};
Ok(device)
}
fn read_varlen(buf: &[u8]) -> Result<(usize, usize)> {
if buf.is_empty() {
return Err(anyhow!("missing varlen"));
}
let first = buf[0];
if first & 0x80 == 0 {
Ok((first as usize, 1))
} else {
if buf.len() < 2 {
return Err(anyhow!("missing varlen second byte"));
}
let len = ((first & 0x7F) as usize) | ((buf[1] as usize) << 7);
Ok((len, 2))
}
}
fn tlv(tag: u8, value: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(2 + value.len());
out.push(tag);
out.extend(encode_varlen(value.len()));
out.extend(value);
out
}
fn encode_varlen(len: usize) -> Vec<u8> {
if len <= 0x7F {
vec![len as u8]
} else {
vec![((len & 0x7F) as u8) | 0x80, (len >> 7) as u8]
}
}
fn fetch_json(url: &str) -> Result<Value> {
let agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(3))
.timeout_read(Duration::from_secs(6))
.build();
let response = agent
.get(url)
.call()
.with_context(|| format!("request failed for {url}"))?;
if response.status() < 200 || response.status() >= 300 {
return Err(anyhow!("http {} for {url}", response.status()));
}
let mut body = String::new();
response
.into_reader()
.read_to_string(&mut body)
.with_context(|| format!("failed to read response body for {url}"))?;
Ok(serde_json::from_str::<Value>(&body)
.with_context(|| format!("invalid json body for {url}"))?)
}
fn try_fetch_discover_json(host: &str) -> Result<Value> {
let url = format!("http://{host}/discover.json");
fetch_json(&url)
}
fn apply_discover_json(device: &mut HdhomerunDevice, json: Value) {
if let Ok(discover) = serde_json::from_value::<DiscoverJson>(json.clone()) {
if let Some(device_id) = discover.device_id {
device.id = DeviceId(device_id);
}
if let Some(tuner_count) = discover.tuner_count {
device.tuner_count = tuner_count;
}
device.lineup_url = discover.lineup_url.or(device.lineup_url.take());
device.discover_url = discover.discover_url.or(device.discover_url.take());
device.base_url = discover.base_url.or(device.base_url.take());
device.device_auth = discover.device_auth.or(device.device_auth.take());
device.friendly_name = discover.friendly_name.or(device.friendly_name.take());
device.model_number = discover.model_number.or(device.model_number.take());
device.firmware_name = discover.firmware_name.or(device.firmware_name.take());
device.firmware_version = discover.firmware_version.or(device.firmware_version.take());
device.device_type = discover.device_type.or(device.device_type.take());
}
device.raw_discover_json = Some(json);
}
fn resolve_lineup_url(device: &HdhomerunDevice) -> Result<String> {
if let Some(lineup_url) = device.lineup_url.as_ref() {
return Ok(lineup_url.clone());
}
if let Some(base_url) = device.base_url.as_ref() {
return Ok(format!("{base_url}/lineup.json"));
}
if !device.ip.is_empty() {
return Ok(format!("http://{}/lineup.json", device.ip));
}
Err(anyhow!("no lineup URL available"))
}
fn append_query_param(url: &str, key: &str, value: &str) -> String {
if url.contains('?') {
format!("{url}&{key}={value}")
} else {
format!("{url}?{key}={value}")
}
}
fn lineup_from_json_value(json: &Value, device_id: Option<&DeviceId>) -> Result<Vec<LineupEntry>> {
let entries = json
.as_array()
.ok_or_else(|| anyhow!("lineup json is not an array"))?;
let mut output = Vec::with_capacity(entries.len());
for (index, entry) in entries.iter().enumerate() {
let parsed: LineupJsonEntry = serde_json::from_value(entry.clone())
.with_context(|| format!("invalid lineup entry at index {index}"))?;
let guide_number = parsed.guide_number.clone();
let guide_name = parsed
.guide_name
.clone()
.or_else(|| guide_number.clone())
.unwrap_or_else(|| format!("Channel {index}"));
let tags = parsed
.tags
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
let url = parsed.url.clone().unwrap_or_else(|| "".to_string());
let id = match (device_id, guide_number.as_ref()) {
(Some(device_id), Some(guide_number)) => {
ChannelId(format!("hdhr:{}:{}", device_id.0, guide_number))
}
(_, Some(guide_number)) => ChannelId(guide_number.clone()),
(_, None) => ChannelId(format!("hdhr:unknown:{index}")),
};
let mut metadata = Vec::new();
for tag in &tags {
metadata.push(ChannelMetadata::Extra("tag".to_string(), tag.clone()));
}
if let Some(guide_number) = guide_number.clone() {
metadata.push(ChannelMetadata::Extra(
"guide_number".to_string(),
guide_number,
));
}
if let Some(obj) = entry.as_object() {
for (key, value) in obj.iter() {
if key == "GuideNumber" || key == "GuideName" || key == "Tags" || key == "URL" {
continue;
}
metadata.push(ChannelMetadata::Extra(key.clone(), value.to_string()));
}
}
let channel = Channel {
id,
name: guide_name,
number: parsed.guide_number,
program_id: None,
metadata,
};
output.push(LineupEntry {
channel,
stream_url: url,
tags,
raw: entry.clone(),
});
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn varlen_roundtrip_small_and_large() {
for len in [0usize, 1, 10, 127, 128, 200, 1024] {
let enc = encode_varlen(len);
let (decoded, consumed) = read_varlen(&enc).unwrap();
assert_eq!(decoded, len);
assert_eq!(consumed, enc.len());
}
}
#[test]
fn parse_discover_response_happy_path() {
let device_id = 0x10ACEBB9u32;
let ip = "192.0.2.10"; // RFC 5737 TEST-NET-1
let mut payload = Vec::new();
payload.extend(tlv(TAG_DEVICE_ID, &device_id.to_be_bytes()));
payload.extend(tlv(TAG_TUNER_COUNT, &[4u8]));
payload.extend(tlv(TAG_BASE_URL, b"http://192.0.2.10\0"));
payload.extend(tlv(TAG_DEVICE_AUTH_STR, b"auth-token\0"));
payload.extend(tlv(0x99, b"unknown"));
let mut packet = Vec::new();
packet.extend(TYPE_DISCOVER_RPY.to_be_bytes());
packet.extend((payload.len() as u16).to_be_bytes());
packet.extend(&payload);
let crc = crc32fast::hash(&packet);
packet.extend(crc.to_le_bytes());
let dev = parse_discover_response(&packet, ip.to_string()).unwrap();
assert_eq!(dev.id.0, "10ACEBB9");
assert_eq!(dev.ip, ip);
assert_eq!(dev.tuner_count, 4);
assert_eq!(dev.base_url.as_deref(), Some("http://192.0.2.10"));
assert_eq!(dev.device_auth.as_deref(), Some("auth-token"));
assert!(dev.discovery_tags.iter().any(|t| t.key == "tag_99"));
}
#[test]
fn parse_discover_response_rejects_bad_crc() {
let mut payload = Vec::new();
payload.extend(tlv(TAG_TUNER_COUNT, &[2u8]));
let mut packet = Vec::new();
packet.extend(TYPE_DISCOVER_RPY.to_be_bytes());
packet.extend((payload.len() as u16).to_be_bytes());
packet.extend(&payload);
let crc = crc32fast::hash(&packet);
packet.extend(crc.to_le_bytes());
// corrupt the last byte
*packet.last_mut().unwrap() ^= 0xFF;
assert!(parse_discover_response(&packet, "1.2.3.4".to_string()).is_err());
}
#[test]
fn lineup_parsing_generates_channel_ids_and_metadata() {
let device_id = DeviceId("ABCDEF01".to_string());
let json = serde_json::json!([
{
"GuideNumber": "2.1",
"GuideName": "KCBS-HD",
"Tags": "drm,encrypted,",
"URL": "http://hdhr/auto/v2.1",
"Foo": "Bar"
},
{
"GuideNumber": "2.2",
"GuideName": "StartTV",
"Tags": "",
"URL": "http://hdhr/auto/v2.2"
}
]);
let entries = lineup_from_json_value(&json, Some(&device_id)).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].channel.id.0, "hdhr:ABCDEF01:2.1");
assert_eq!(entries[0].channel.name, "KCBS-HD");
assert_eq!(entries[0].channel.number.as_deref(), Some("2.1"));
assert_eq!(entries[0].stream_url, "http://hdhr/auto/v2.1");
assert!(entries[0].tags.iter().any(|t| t == "drm"));
assert!(entries[0].channel.metadata.iter().any(|m| match m {
ChannelMetadata::Extra(key, value) => key == "guide_number" && value == "2.1",
_ => false,
}));
assert!(entries[0].channel.metadata.iter().any(|m| match m {
ChannelMetadata::Extra(key, _) => key == "Foo",
_ => false,
}));
}
}