gbin 0.4.0

A suite of Linux utilities and programs.
use serde_json::Value;
use std::ffi::OsStr;
use tokio::process::Command;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    Stream,
    Sink,
    Source,
}
impl Kind {
    fn noun(self) -> &'static str {
        match self {
            Kind::Stream => "sink-input",
            Kind::Sink => "sink",
            Kind::Source => "source",
        }
    }
}

#[derive(Debug, Clone)]
pub struct Entry {
    pub index: u32,
    pub name: String,
    pub label: String,
    pub detail: String,
    pub volume: u32,
    pub mute: bool,
    pub default: bool,
}

#[derive(Debug, Clone, Default)]
pub struct Snapshot {
    pub streams: Vec<Entry>,
    pub sinks: Vec<Entry>,
    pub sources: Vec<Entry>,
}

#[derive(Debug, Clone)]
pub struct Pactl {
    pub server: &'static str,
}
impl Pactl {
    pub async fn new() -> anyhow::Result<Self> {
        let info = run(["info"]).await?;
        let server = if info.contains("PipeWire") {
            "PipeWire"
        } else {
            "PulseAudio"
        };
        Ok(Self { server })
    }

    pub async fn snapshot(&self) -> anyhow::Result<Snapshot> {
        let (streams, sinks, sources, default_sink, default_source) = tokio::try_join!(
            run(["--format=json", "list", "sink-inputs"]),
            run(["--format=json", "list", "sinks"]),
            run(["--format=json", "list", "sources"]),
            run(["get-default-sink"]),
            run(["get-default-source"]),
        )?;
        Ok(Snapshot {
            streams: parse(&streams, Kind::Stream, "")?,
            sinks: parse(&sinks, Kind::Sink, default_sink.trim())?,
            sources: parse(&sources, Kind::Source, default_source.trim())?,
        })
    }

    pub async fn set_volume(&self, kind: Kind, index: u32, percent: u32) -> anyhow::Result<()> {
        run([
            format!("set-{}-volume", kind.noun()),
            index.to_string(),
            format!("{percent}%"),
        ])
        .await?;
        Ok(())
    }

    pub async fn set_mute(&self, kind: Kind, index: u32, mute: bool) -> anyhow::Result<()> {
        run([
            format!("set-{}-mute", kind.noun()),
            index.to_string(),
            if mute { "1" } else { "0" }.to_string(),
        ])
        .await?;
        Ok(())
    }

    pub async fn set_default(&self, kind: Kind, name: &str) -> anyhow::Result<()> {
        run([format!("set-default-{}", kind.noun()), name.to_string()]).await?;
        Ok(())
    }
}

async fn run<I, S>(args: I) -> anyhow::Result<String>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    let output = Command::new("pactl")
        .args(args)
        .env("LC_ALL", "C")
        .output()
        .await?;
    if !output.status.success() {
        anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr).trim());
    }
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

fn parse(json: &str, kind: Kind, default: &str) -> anyhow::Result<Vec<Entry>> {
    let items: Vec<Value> = serde_json::from_str(json)?;
    Ok(items
        .iter()
        .filter_map(|item| {
            let name = item["name"].as_str().unwrap_or_default().to_string();
            if kind == Kind::Source && name.ends_with(".monitor") {
                return None;
            }
            let properties = &item["properties"];
            let (label, detail) = match kind {
                Kind::Stream => (
                    properties["application.name"]
                        .as_str()
                        .or(properties["application.process.binary"].as_str())
                        .unwrap_or("Unknown")
                        .to_string(),
                    properties["media.name"]
                        .as_str()
                        .unwrap_or_default()
                        .to_string(),
                ),
                _ => (
                    item["description"].as_str().unwrap_or(&name).to_string(),
                    String::new(),
                ),
            };
            Some(Entry {
                index: item["index"].as_u64()? as u32,
                default: kind != Kind::Stream && name == default,
                name,
                label,
                detail,
                volume: volume_percent(item),
                mute: item["mute"].as_bool().unwrap_or(false),
            })
        })
        .collect())
}

fn volume_percent(item: &Value) -> u32 {
    let values = item["volume"]
        .as_object()
        .map(|channels| {
            channels
                .values()
                .filter_map(|channel| channel["value"].as_u64())
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    if values.is_empty() {
        return 0;
    }
    let average = values.iter().sum::<u64>() as f64 / values.len() as f64;
    (average * 100.0 / 65536.0).round() as u32
}