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