//! 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, pub discover_url: Option, pub base_url: Option, pub device_auth: Option, pub friendly_name: Option, pub model_number: Option, pub firmware_name: Option, pub firmware_version: Option, pub device_type: Option, pub discovery_tags: Vec, pub raw_discover_json: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LineupEntry { pub channel: Channel, pub stream_url: String, pub tags: Vec, pub raw: Value, } pub struct HdhomerunStream { pub url: String, reader: Box, } 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 { self.reader.read(buf) } } #[derive(Debug, Clone, Deserialize)] struct DiscoverJson { #[serde(rename = "DeviceID")] device_id: Option, #[serde(rename = "DeviceAuth")] device_auth: Option, #[serde(rename = "BaseURL")] base_url: Option, #[serde(rename = "LineupURL")] lineup_url: Option, #[serde(rename = "DiscoverURL")] discover_url: Option, #[serde(rename = "FriendlyName")] friendly_name: Option, #[serde(rename = "ModelNumber")] model_number: Option, #[serde(rename = "FirmwareName")] firmware_name: Option, #[serde(rename = "FirmwareVersion")] firmware_version: Option, #[serde(rename = "DeviceType")] device_type: Option, #[serde(rename = "TunerCount")] tuner_count: Option, } #[derive(Debug, Clone, Deserialize)] struct LineupJsonEntry { #[serde(rename = "GuideNumber")] guide_number: Option, #[serde(rename = "GuideName")] guide_name: Option, #[serde(rename = "Tags")] tags: Option, #[serde(rename = "URL")] url: Option, } /// Discover devices using UDP broadcast, then hydrate with /discover.json when possible. pub fn discover() -> Result> { 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 { 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> { 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> { 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 { 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, ) -> Result { 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) -> Result { 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> { 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> { 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 { 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 = None; let mut tuner_count: Option = None; let mut base_url: Option = None; let mut device_auth: Option = None; let mut tags: Vec = 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 { 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 { if len <= 0x7F { vec![len as u8] } else { vec![((len & 0x7F) as u8) | 0x80, (len >> 7) as u8] } } fn fetch_json(url: &str) -> Result { 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::(&body) .with_context(|| format!("invalid json body for {url}"))?) } fn try_fetch_discover_json(host: &str) -> Result { 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::(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 { 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> { 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::>(); 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(callsign) = json_string_field(entry, &["CallSign", "Callsign", "CallSignRaw"]) { metadata.push(ChannelMetadata::Callsign(callsign)); } if let Some(network) = json_string_field(entry, &["Network"]) { metadata.push(ChannelMetadata::Network(network)); } if let Some(region) = json_string_field(entry, &["Region", "Market"]) { metadata.push(ChannelMetadata::Region(region)); } if let Some(frequency) = json_string_field(entry, &["Frequency", "FrequencyHz"]) { metadata.push(ChannelMetadata::Frequency(frequency)); } let program_id = json_u16_field(entry, &["ProgramNumber", "ProgramID", "Program"]); if let Some(obj) = entry.as_object() { for (key, value) in obj.iter() { if key == "GuideNumber" || key == "GuideName" || key == "Tags" || key == "URL" || key == "CallSign" || key == "Callsign" || key == "CallSignRaw" || key == "Network" || key == "Region" || key == "Market" || key == "Frequency" || key == "FrequencyHz" || key == "ProgramNumber" || key == "ProgramID" || key == "Program" { continue; } metadata.push(ChannelMetadata::Extra(key.clone(), value.to_string())); } } let channel = Channel { id, name: guide_name, number: parsed.guide_number, program_id, metadata, }; output.push(LineupEntry { channel, stream_url: url, tags, raw: entry.clone(), }); } Ok(output) } fn json_string_field(value: &Value, keys: &[&str]) -> Option { let obj = value.as_object()?; for key in keys { let Some(value) = obj.get(*key) else { continue; }; let text = match value { Value::String(text) => text.trim().to_string(), Value::Number(number) => number.to_string(), _ => continue, }; if !text.is_empty() { return Some(text); } } None } fn json_u16_field(value: &Value, keys: &[&str]) -> Option { let obj = value.as_object()?; for key in keys { let Some(value) = obj.get(*key) else { continue; }; let parsed = match value { Value::Number(number) => number .as_u64() .and_then(|number| u16::try_from(number).ok()), Value::String(text) => text.trim().parse::().ok(), _ => None, }; if parsed.is_some() { return parsed; } } None } #[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", "CallSign": "KCBS", "ProgramNumber": "3", "Frequency": "573000000", "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].channel.program_id, Some(3)); 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::Callsign(value) => value == "KCBS", _ => false, })); assert!(entries[0].channel.metadata.iter().any(|m| match m { ChannelMetadata::Frequency(value) => value == "573000000", _ => false, })); 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, })); } }