756 lines
25 KiB
Rust
756 lines
25 KiB
Rust
//! 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(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<String> {
|
|
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<u16> {
|
|
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::<u16>().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,
|
|
}));
|
|
}
|
|
}
|