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