🏡 index : ~doyle/sonos-cli.git

#[macro_use] extern crate clap;
#[macro_use] extern crate log;
#[macro_use] extern crate serde_derive;
#[macro_use] extern crate failure;

#[macro_use] mod util;
mod discovery;

use std::time::Duration;
use std::net::IpAddr;

use sonos::Speaker;

use failure::Fallible;
use crate::discovery::find_speaker_by_name;

fn argparse<'a, 'b>() -> clap::App<'a, 'b> {
    use clap::{App, AppSettings, Arg, SubCommand};

    App::new("sonos")
        .version(crate_version!())
        .author("Jordan Doyle <jordan@doyle.la>")
        .about("Control your Sonos using the command line")
        .setting(AppSettings::SubcommandRequired)
        .arg(Arg::with_name("controller")
                .help("Set the controller to run operation on")
                .short("c")
                .required_unless("rooms")
                .value_name("IP or Room Name")
                .takes_value(true))
        .arg(Arg::with_name("json")
                .help("Return back JSON serialised responses for programmatic use of the CLI"))
        .subcommand(SubCommand::with_name("info").about("Shows information about the speaker"))
        .subcommand(
            SubCommand::with_name("track")
                .about("Commands to manipulate the tracklist")
                .subcommand(SubCommand::with_name("next").about("Skip to the next track"))
                .subcommand(SubCommand::with_name("prev").about("Go back to the last track"))
                .subcommand(SubCommand::with_name("list").about("Get the list of tracks in the queue"))
                .subcommand(
                    SubCommand::with_name("play")
                        .about("Play a given track")
                        .subcommand(SubCommand::with_name("tv").about("Set the current speaker's input to the SPDIF"))
                        .subcommand(SubCommand::with_name("line-in").about("Set the current speaker's input to the line-in"))
                        .arg(Arg::with_name("uri").help("Queue position to skip to or a Sonos URI to play").index(1).conflicts_with_all(&["tv", "line-in"]))
                )
        )
        .subcommand(SubCommand::with_name("group").about("Group this speaker with the given master")
                        .arg(Arg::with_name("MASTER")
                                .help("Name of the speaker to group with")
                                .required(true)
                                .index(1)))
        .subcommand(SubCommand::with_name("ungroup").about("Ungroup this speaker from the master"))
        .subcommand(SubCommand::with_name("seek").about("Seek to a specific timestamp on the current track")
                        .arg(Arg::with_name("TIMESTAMP")
                                .help("hh:mm:ss/mm:ss")
                                .required(true)
                                .index(1)))
        .subcommand(SubCommand::with_name("volume").about("Get or set the volume of the speaker")
                        .arg(Arg::with_name("VOLUME")
                                .help("Percent volume to set speaker to 0-100")
                                .index(1)))
        .subcommand(SubCommand::with_name("rooms").about("List all of your speakers")
                        .arg(Arg::with_name("invalidate").help("Detect new speakers and room arrangements")))
}

#[tokio::main]
async fn main() -> Fallible<()> {
    let args = argparse().get_matches();

    util::setup_logger()?;

    let controller = args.value_of("controller").expect("controller");
    let speaker = match controller.parse::<IpAddr>() {
        Ok(ip) => Speaker::from_ip(ip).await?,
        Err(_) => discovery::find_speaker_by_name(controller).await?,
    };

    match args.subcommand() {
        ("track", Some(subargs)) => {
            match subargs.subcommand() {
                ("next", _) => speaker.queue().next().await?,
                ("prev", _) => speaker.queue().previous().await?,
                ("list", _) => print_struct!(args, &TrackList::new(&speaker).await?),
                ("play", Some(play_subargs)) => match play_subargs.subcommand_name() {
                    Some("tv") => speaker.play_tv().await?,
                    Some("line-in") => speaker.play_line_in().await?,
                    _ => {
                        let uri = play_subargs.value_of("uri")
                            .filter(|s| !s.is_empty())
                            .ok_or_else(|| format_err!("Must pass [tv], [line-in] or a URI to the play command"))?;

                        if let Ok(pos) = uri.parse::<u64>() {
                            speaker.queue().skip_to(&pos).await?
                        } else {
                            speaker.play_track(uri).await?
                        }
                    },
                },
                _ => print_struct!(args, &Track::new(&speaker).await?)
            }
        },
        ("group", Some(sub)) => {
            let master = sub.value_of("MASTER").expect("master");
            speaker.group(&match master.parse::<IpAddr>() {
                Ok(ip) => Speaker::from_ip(ip).await?,
                Err(_) => discovery::find_speaker_by_name(master).await?,
            }).await?
        },
        ("ungroup", _) => speaker.ungroup().await?,
        ("info", _) => print_struct!(args, &Info::new(&speaker)),
        ("volume", Some(sub)) => match sub.value_of("VOLUME") {
            Some(volume) => speaker.set_volume(volume.parse()?).await?,
            None => print_struct!(args, &Volume::new(&speaker).await?),
        },
        ("seek", Some(sub)) => {
            let a = sub.value_of("TIMESTAMP").expect("timestamp");

            let mut multiplier = 1;

            let secs = a.split(":").collect::<Vec<&str>>().iter().rfold(0, |curr, iter_val| {
                let section_value = iter_val.parse::<u64>().expect("can't parse int") * multiplier;
                multiplier *= 60;
                curr + section_value
            });

            let duration = Duration::new(secs, 0);

            speaker.seek(&duration).await?;
        },
        ("rooms", Some(sub)) => {
            let devices = discovery::discover(true, sub.is_present("invalidate")).await?;

            let mut rooms = std::collections::HashMap::new();

            for device in devices {
                let coordinator = device.coordinator().await?;

                let mut room = rooms.entry(coordinator).or_insert(Vec::new());
                room.push(device);
            }

            for (key, value) in rooms {
                info!("Controller: {}", key);

                for device in value {
                    info!("d:     {}", device.name);
                }
            }
        },
        _ => {
            panic!();
        }
    }

    Ok(())
}

#[derive(Serialize, Deserialize, Debug)]
struct TrackListItem {
    pub position: u64,
    pub title: String,
    pub artist: String,
    pub album: String,
    pub duration: Duration
}
#[derive(Serialize, Deserialize, Debug)]
struct TrackList(Vec<TrackListItem>);
impl TrackList {
    pub async fn new(speaker: &Speaker) -> Fallible<Self> {
        Ok(Self(
            speaker.queue().list().await?
                .into_iter()
                .map(|v| TrackListItem {
                    position: v.position,
                    title: v.title,
                    artist: v.artist,
                    album: v.album,
                    duration: v.duration
                })
                .collect()
        ))
    }
}
impl std::fmt::Display for TrackList {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        Ok(for item in &self.0 {
            writeln!(f, "{}: {} - {} ({})",
                   item.position,
                   item.artist,
                   item.title,
                   util::duration_to_hms(item.duration))?
        })
    }
}

#[derive(Serialize, Deserialize, Debug)]
struct Track {
    pub title: String,
    pub artist: String,
    pub album: Option<String>,
    pub running_time: Duration,
    pub duration: Duration
}
impl Track {
    pub async fn new(speaker: &Speaker) -> Fallible<Track> {
        let track = speaker.track().await?;

        Ok(Self {
            title: track.title,
            artist: track.artist,
            album: track.album,
            running_time: track.running_time,
            duration: track.duration
        })
    }
}
impl std::fmt::Display for Track {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        writeln!(f, "\u{1F3A4}  {}", self.artist)?;
        writeln!(f, "\u{1F3B5}  {}", self.title)?;

        if let Some(album) = &self.album {
            writeln!(f, "\u{1F4BF}  {}", album)?;
        }

        let running_time = util::duration_to_hms(self.running_time);
        let duration = util::duration_to_hms(self.duration);

        write!(f, "\u{23F1}\u{FE0F}  {}/{}", running_time, duration)?;

        const PROG_BAR_LEN: usize = 25;
        let percent_played = ((self.running_time.as_secs() as f64 / self.duration.as_secs() as f64) * PROG_BAR_LEN as f64) as usize;
        write!(f, " [{}{}]", "\u{2587}".repeat(percent_played), "-".repeat(PROG_BAR_LEN - percent_played))
    }
}

#[derive(Serialize, Deserialize, Debug)]
struct Volume {
    volume: u8,
    muted: bool,
}
impl Volume {
    pub async fn new(speaker: &Speaker) -> Result<Volume, failure::Error> {
        Ok(Self {
            volume: speaker.volume().await?,
            muted: speaker.muted().await?,
        })
    }
}
impl std::fmt::Display for Volume {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        const MAX_VOLUME: usize = 100;
        const PROG_BAR_LEN: usize = 25;

        let pictogram = if self.muted {
            "\u{1F507}"
        } else {
            "\u{1F50A}"
        };

        write!(f, "{} {}/{}", pictogram, self.volume, MAX_VOLUME)?;

        let percent = (self.volume as usize * PROG_BAR_LEN) / MAX_VOLUME;

        write!(f, " [{}{}]", "\u{2587}".repeat(percent), "-".repeat(PROG_BAR_LEN - percent))
    }
}

#[derive(Serialize, Deserialize, Debug)]
struct Info {
    pub ip: IpAddr,
    pub model: String,
    pub model_number: String,
    pub software_version: String,
    pub hardware_version: String,
    pub serial_number: String,
    pub name: String,
    pub uuid: String,
}
impl Info {
    pub fn new(speaker: &Speaker) -> Info {
        Info {
            ip: speaker.ip.clone(),
            model: speaker.model.clone(),
            model_number: speaker.model_number.clone(),
            software_version: speaker.software_version.clone(),
            hardware_version: speaker.hardware_version.clone(),
            serial_number: speaker.serial_number.clone(),
            name: speaker.name.clone(),
            uuid: speaker.uuid.clone(),
        }
    }
}
impl std::fmt::Display for Info {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        writeln!(f, "\u{1F508}  {}", self.name)?;
        writeln!(f, "{}", "=".repeat(self.name.len() + 3))?;

        writeln!(f, "Model: {} ({})", self.model, self.model_number)?;

        writeln!(f, "Versions: Software {}, Hardware {}", self.software_version, self.hardware_version)?;
        writeln!(f, "Serial number: {}", self.serial_number)?;
        writeln!(f, "UUID: {}", self.uuid)
    }
}