tunein-cli 0.7.1

Browse and listen to thousands of radio stations across the globe right from your terminal 🌎 📻 🎵✨
use crate::types::Station;

use super::{is_valid_uuid, Provider};
use anyhow::Error;
use async_trait::async_trait;
use radiobrowser::{ApiStation, ApiTag, RadioBrowserAPI};
use std::process::exit;

pub struct Radiobrowser {
    client: reqwest::Client,
    base_url: String,
}

impl Radiobrowser {
    pub async fn new() -> Self {
        let servers = match RadioBrowserAPI::get_default_servers().await {
            Ok(servers) if !servers.is_empty() => servers,
            _ => {
                eprintln!("Failed to create a RadioBrowserAPI client");
                exit(1);
            }
        };

        Self::with_base_url(format!("https://{}", servers[0]))
    }

    pub fn with_base_url(base_url: impl Into<String>) -> Self {
        let client = reqwest::Client::builder()
            .user_agent(concat!("tunein-cli/", env!("CARGO_PKG_VERSION")))
            .build()
            .expect("Failed to create an HTTP client");
        Self {
            client,
            base_url: base_url.into(),
        }
    }

    async fn search_stations(&self, query: &[(&str, &str)]) -> Result<Vec<ApiStation>, Error> {
        let stations = self
            .client
            .get(format!("{}/json/stations/search", self.base_url))
            .query(query)
            .send()
            .await?
            .json::<Vec<ApiStation>>()
            .await?;
        Ok(stations)
    }
}

#[async_trait]
impl Provider for Radiobrowser {
    async fn search(&self, name: String) -> Result<Vec<Station>, Error> {
        let stations = self.search_stations(&[("name", name.as_str())]).await?;
        Ok(stations.into_iter().map(Station::from).collect())
    }

    async fn get_station(&self, name_or_uuid: String) -> Result<Option<Station>, Error> {
        let stations = match is_valid_uuid(&name_or_uuid) {
            true => {
                self.client
                    .get(format!(
                        "{}/json/stations/byuuid/{}",
                        self.base_url, name_or_uuid
                    ))
                    .send()
                    .await?
                    .json::<Vec<ApiStation>>()
                    .await?
            }
            false => {
                self.search_stations(&[("name", name_or_uuid.as_str()), ("nameExact", "true")])
                    .await?
            }
        };

        Ok(stations.into_iter().next().map(Station::from))
    }

    async fn browse(
        &self,
        category: String,
        offset: u32,
        limit: u32,
    ) -> Result<Vec<Station>, Error> {
        let offset = offset.to_string();
        let limit = limit.to_string();
        let stations = self
            .search_stations(&[
                ("tag", category.as_str()),
                ("offset", offset.as_str()),
                ("limit", limit.as_str()),
            ])
            .await?;
        Ok(stations.into_iter().map(Station::from).collect())
    }

    async fn categories(&self, offset: u32, limit: u32) -> Result<Vec<String>, Error> {
        let categories = self
            .client
            .get(format!("{}/json/tags", self.base_url))
            .query(&[("offset", offset), ("limit", limit)])
            .send()
            .await?
            .json::<Vec<ApiTag>>()
            .await?;
        Ok(categories.into_iter().map(|x| x.name).collect())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use mockito::{Matcher, Server, ServerGuard};

    fn station_json(uuid: &str, name: &str) -> serde_json::Value {
        serde_json::json!({
            "changeuuid": "610cafba-71d8-40fc-bf68-1456ec973b9d",
            "stationuuid": uuid,
            "name": name,
            "url": "https://example.com/stream",
            "url_resolved": "https://example.com/stream",
            "homepage": "https://example.com",
            "favicon": "https://example.com/favicon.ico",
            "tags": "music",
            "country": "The United States Of America",
            "countrycode": "US",
            "state": "",
            "language": "english",
            "votes": 10,
            "codec": "MP3",
            "bitrate": 128,
            "hls": 0,
            "lastcheckok": 1,
            "clickcount": 100,
            "clicktrend": 0
        })
    }

    async fn mock_provider() -> (ServerGuard, Radiobrowser) {
        let server = Server::new_async().await;
        let provider = Radiobrowser::with_base_url(server.url());
        (server, provider)
    }

    #[tokio::test]
    pub async fn test_search() {
        let (mut server, provider) = mock_provider().await;
        let name = "alternativeradio";
        let body = serde_json::json!([station_json(
            "964da563-0601-11e8-ae97-52543be04c81",
            "AlternativeRadio.us"
        )]);
        let mock = server
            .mock("GET", "/json/stations/search")
            .match_query(Matcher::UrlEncoded("name".into(), name.into()))
            .with_header("content-type", "application/json")
            .with_body(body.to_string())
            .create_async()
            .await;

        let stations = provider.search(name.to_string()).await.unwrap();

        mock.assert_async().await;
        assert!(stations.len() == 1);
        assert_eq!(stations[0].name, "AlternativeRadio.us");
    }

    #[tokio::test]
    pub async fn test_get_station() {
        let (mut server, provider) = mock_provider().await;
        let name = "AlternativeRadio.us";
        let body = serde_json::json!([station_json("964da563-0601-11e8-ae97-52543be04c81", name)]);
        let mock = server
            .mock("GET", "/json/stations/search")
            .match_query(Matcher::AllOf(vec![
                Matcher::UrlEncoded("name".into(), name.into()),
                Matcher::UrlEncoded("nameExact".into(), "true".into()),
            ]))
            .with_header("content-type", "application/json")
            .with_body(body.to_string())
            .create_async()
            .await;

        let station = provider.get_station(name.to_string()).await.unwrap();

        mock.assert_async().await;
        assert!(station.is_some());
    }

    #[tokio::test]
    pub async fn test_get_station_by_uuid() {
        let (mut server, provider) = mock_provider().await;
        let uuid = "964da563-0601-11e8-ae97-52543be04c81";
        let body = serde_json::json!([station_json(uuid, "AlternativeRadio.us")]);
        let mock = server
            .mock("GET", &format!("/json/stations/byuuid/{}", uuid)[..])
            .with_header("content-type", "application/json")
            .with_body(body.to_string())
            .create_async()
            .await;

        let station = provider.get_station(uuid.to_string()).await.unwrap();

        mock.assert_async().await;
        assert!(station.is_some());
        assert_eq!(station.unwrap().id, uuid);
    }

    #[tokio::test]
    pub async fn test_browse() {
        let (mut server, provider) = mock_provider().await;
        let body = serde_json::Value::Array(
            (0..100)
                .map(|i| station_json(&format!("uuid-{}", i), &format!("Station {}", i)))
                .collect(),
        );
        let mock = server
            .mock("GET", "/json/stations/search")
            .match_query(Matcher::AllOf(vec![
                Matcher::UrlEncoded("tag".into(), "music".into()),
                Matcher::UrlEncoded("offset".into(), "0".into()),
                Matcher::UrlEncoded("limit".into(), "100".into()),
            ]))
            .with_header("content-type", "application/json")
            .with_body(body.to_string())
            .create_async()
            .await;

        let stations = provider.browse("music".to_string(), 0, 100).await.unwrap();

        mock.assert_async().await;
        assert!(stations.len() == 100);
    }

    #[tokio::test]
    pub async fn test_categories() {
        let (mut server, provider) = mock_provider().await;
        let body = serde_json::json!([
            { "name": "music", "stationcount": 42 },
            { "name": "news", "stationcount": 7 }
        ]);
        let mock = server
            .mock("GET", "/json/tags")
            .match_query(Matcher::AllOf(vec![
                Matcher::UrlEncoded("offset".into(), "0".into()),
                Matcher::UrlEncoded("limit".into(), "100".into()),
            ]))
            .with_header("content-type", "application/json")
            .with_body(body.to_string())
            .create_async()
            .await;

        let categories = provider.categories(0, 100).await.unwrap();

        mock.assert_async().await;
        assert!(categories.len() > 0);
        assert_eq!(categories, vec!["music", "news"]);
    }
}