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,292 @@
//! Linux IPTV (LinuxDVB) ingest scaffolding.
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Child;
#[cfg(target_os = "linux")]
use std::{process::Command, time::Duration};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinuxDvbConfig {
pub adapter: u32,
pub frontend: u32,
pub dvr: u32,
pub tune_command: Option<Vec<String>>,
pub tune_timeout_ms: Option<u64>,
}
#[derive(Debug)]
pub struct LinuxDvbStream {
file: File,
_tuner: Option<Child>,
pub path: PathBuf,
}
impl Read for LinuxDvbStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.file.read(buf)
}
}
/// Open the Linux DVB DVR device. Optionally spawns a tune command (like dvbv5-zap).
#[cfg(target_os = "linux")]
pub fn open_stream(config: &LinuxDvbConfig) -> Result<LinuxDvbStream> {
let tuner = if let Some(cmd) = config.tune_command.clone() {
spawn_tune_command(cmd, config.tune_timeout_ms)?
} else {
None
};
let path = dvb_path(config.adapter, config.dvr);
let file =
File::open(&path).map_err(|err| anyhow!("failed to open {}: {err}", path.display()))?;
Ok(LinuxDvbStream {
file,
_tuner: tuner,
path,
})
}
#[cfg(not(target_os = "linux"))]
pub fn open_stream(_config: &LinuxDvbConfig) -> Result<LinuxDvbStream> {
Err(anyhow!("Linux DVB support requires Linux"))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinuxDvbAdapterInfo {
pub adapter: u32,
pub dvrs: Vec<u32>,
pub frontends: Vec<u32>,
}
pub fn list_adapters() -> Result<Vec<LinuxDvbAdapterInfo>> {
list_adapters_in(Path::new("/dev/dvb"))
}
fn list_adapters_in(root: &Path) -> Result<Vec<LinuxDvbAdapterInfo>> {
if !root.exists() {
return Ok(Vec::new());
}
let mut adapters = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.starts_with("adapter") {
continue;
}
let Ok(adapter) = name.trim_start_matches("adapter").parse::<u32>() else {
continue;
};
let path = entry.path();
let mut dvrs = BTreeSet::new();
let mut frontends = BTreeSet::new();
for dev in fs::read_dir(&path)? {
let dev = dev?;
let dev_name = dev.file_name().to_string_lossy().to_string();
if dev_name.starts_with("dvr") {
if let Ok(idx) = dev_name.trim_start_matches("dvr").parse::<u32>() {
dvrs.insert(idx);
}
} else if dev_name.starts_with("frontend") {
if let Ok(idx) = dev_name.trim_start_matches("frontend").parse::<u32>() {
frontends.insert(idx);
}
}
}
adapters.push(LinuxDvbAdapterInfo {
adapter,
dvrs: dvrs.into_iter().collect(),
frontends: frontends.into_iter().collect(),
});
}
adapters.sort_by_key(|info| info.adapter);
Ok(adapters)
}
pub fn channels_conf_candidates() -> Vec<PathBuf> {
// Prefer an explicit path for determinism and testability.
if let Ok(value) = std::env::var("EVERY_CHANNEL_DVB_CHANNELS_CONF") {
let value = value.trim();
if !value.is_empty() {
return vec![PathBuf::from(value)];
}
}
let home = std::env::var("HOME").ok().map(PathBuf::from);
let mut out = Vec::new();
if let Some(home) = home {
out.push(home.join(".dvb").join("channels.conf"));
out.push(home.join(".config").join("dvb").join("channels.conf"));
}
out.push(PathBuf::from("/etc/dvb/channels.conf"));
out
}
pub fn find_channels_conf() -> Option<PathBuf> {
for candidate in channels_conf_candidates() {
if candidate.exists() {
return Some(candidate);
}
}
None
}
pub fn parse_channels_conf(path: &Path) -> Result<Vec<String>> {
let text = fs::read_to_string(path)
.map_err(|err| anyhow!("failed to read {}: {err}", path.display()))?;
let mut channels = BTreeSet::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((name, _)) = line.split_once(':') {
let name = name.trim();
if !name.is_empty() {
channels.insert(name.to_string());
}
}
}
Ok(channels.into_iter().collect())
}
pub fn default_zap_tune_command(adapter: u32, channels_conf: &Path, channel: &str) -> Vec<String> {
vec![
"dvbv5-zap".to_string(),
"-a".to_string(),
adapter.to_string(),
"-c".to_string(),
channels_conf.display().to_string(),
"-r".to_string(),
channel.to_string(),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_channels_conf_extracts_names() {
let dir = std::env::temp_dir().join(format!("ec-channels-{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
let path = dir.join("channels.conf");
fs::write(
&path,
"\
# comment
KQED:foo
KQED:duplicate
KCBS-HD:bar
",
)
.unwrap();
let channels = parse_channels_conf(&path).unwrap();
assert_eq!(channels, vec!["KCBS-HD".to_string(), "KQED".to_string()]);
let _ = fs::remove_file(&path);
}
#[test]
fn default_zap_command_contains_adapter_and_channel() {
let conf = Path::new("/tmp/channels.conf");
let cmd = default_zap_tune_command(2, conf, "KQED");
assert_eq!(cmd[0], "dvbv5-zap");
assert!(cmd.iter().any(|arg| arg == "2"));
assert!(cmd.iter().any(|arg| arg == "KQED"));
}
#[test]
fn find_channels_conf_prefers_env_override() {
let dir = std::env::temp_dir().join(format!("ec-channels-env-{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
let path = dir.join("channels.conf");
fs::write(&path, "KQED:foo\n").unwrap();
let prev = std::env::var("EVERY_CHANNEL_DVB_CHANNELS_CONF").ok();
std::env::set_var(
"EVERY_CHANNEL_DVB_CHANNELS_CONF",
path.display().to_string(),
);
let found = find_channels_conf().unwrap();
assert_eq!(found, path);
match prev {
Some(value) => std::env::set_var("EVERY_CHANNEL_DVB_CHANNELS_CONF", value),
None => std::env::remove_var("EVERY_CHANNEL_DVB_CHANNELS_CONF"),
}
let _ = fs::remove_file(&path);
}
#[test]
fn list_adapters_parses_fake_dev_tree() {
let root = std::env::temp_dir().join(format!("ec-dvb-root-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("adapter1")).unwrap();
fs::create_dir_all(root.join("adapter0")).unwrap();
fs::write(root.join("adapter0").join("dvr0"), "").unwrap();
fs::write(root.join("adapter0").join("frontend0"), "").unwrap();
fs::write(root.join("adapter1").join("dvr2"), "").unwrap();
fs::write(root.join("adapter1").join("frontend0"), "").unwrap();
fs::write(root.join("adapter1").join("frontend1"), "").unwrap();
let list = list_adapters_in(&root).unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].adapter, 0);
assert_eq!(list[0].dvrs, vec![0]);
assert_eq!(list[0].frontends, vec![0]);
assert_eq!(list[1].adapter, 1);
assert_eq!(list[1].dvrs, vec![2]);
assert_eq!(list[1].frontends, vec![0, 1]);
let _ = fs::remove_dir_all(&root);
}
}
#[cfg(target_os = "linux")]
fn spawn_tune_command(command: Vec<String>, tune_timeout_ms: Option<u64>) -> Result<Option<Child>> {
if command.is_empty() {
return Ok(None);
}
let mut cmd = Command::new(&command[0]);
if command.len() > 1 {
cmd.args(&command[1..]);
}
let child = cmd.spawn()?;
if let Some(timeout_ms) = tune_timeout_ms {
std::thread::sleep(Duration::from_millis(timeout_ms));
}
Ok(Some(child))
}
#[cfg(not(target_os = "linux"))]
fn spawn_tune_command(
_command: Vec<String>,
_tune_timeout_ms: Option<u64>,
) -> Result<Option<Child>> {
Ok(None)
}
fn dvb_path(adapter: u32, dvr: u32) -> PathBuf {
Path::new("/dev/dvb")
.join(format!("adapter{adapter}"))
.join(format!("dvr{dvr}"))
}