diff --git a/src/provider/tunein.rs b/src/provider/tunein.rs index 9712e05..0611d5b 100644 --- a/src/provider/tunein.rs +++ b/src/provider/tunein.rs @@ -3,49 +3,103 @@ use crate::types::Station; use super::Provider; use anyhow::Error; use async_trait::async_trait; -use tunein::TuneInClient; +use tunein::types::{ + CategoriesResponse, Category, CategoryDetails, CategoryResponse, CategoryTrait, SearchResponse, + SearchResult, StationLinkDetails, StationResponse, +}; pub struct Tunein { - client: TuneInClient, + client: reqwest::Client, + base_url: String, } impl Tunein { pub fn new() -> Self { + Self::with_base_url(tunein::BASE_URL) + } + + pub fn with_base_url(base_url: impl Into) -> 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: TuneInClient::new(), + client, + base_url: base_url.into(), + } + } + + async fn search_results(&self, query: &str) -> Result, Error> { + let response = self + .client + .get(format!("{}/Search.ashx", self.base_url)) + .query(&[("query", query), ("render", "json")]) + .send() + .await? + .json::() + .await?; + Ok(response.body) + } + + async fn station_links(&self, id: &str) -> Result, Error> { + let response = self + .client + .get(format!("{}/Tune.ashx", self.base_url)) + .query(&[("id", id), ("render", "json")]) + .send() + .await? + .json::() + .await?; + Ok(response.body) + } + + async fn browse_categories( + &self, + category: Option, + ) -> Result, Error> { + let mut request = self.client.get(format!("{}/Browse.ashx", self.base_url)); + if let Some(category) = category { + request = request.query(&[("c", category.to_id())]); } + let response = request + .query(&[("render", "json")]) + .send() + .await? + .json::() + .await?; + Ok(response.body) + } + + async fn browse_stations(&self, id: &str) -> Result, Error> { + let response = self + .client + .get(format!("{}/Browse.ashx", self.base_url)) + .query(&[("id", id), ("render", "json")]) + .send() + .await? + .json::() + .await?; + Ok(response.body) } } #[async_trait] impl Provider for Tunein { async fn search(&self, name: String) -> Result, Error> { - let results = self - .client - .search(&name) - .await - .map_err(|e| Error::msg(e.to_string()))?; + let results = self.search_results(&name).await?; let stations = results.into_iter().map(|x| Station::from(x)).collect(); Ok(stations) } async fn get_station(&self, id: String) -> Result, Error> { - let stations = self - .client - .get_station(&id) - .await - .map_err(|e| Error::msg(e.to_string()))?; + let stations = self.station_links(&id).await?; match stations.len() { 0 => { let results = self.search(id.clone()).await?; let station = results.first().cloned(); match station { Some(st) => { - let stations = self - .client - .get_station(&st.id) - .await - .map_err(|e| Error::msg(e.to_string()))?; + let stations = self.station_links(&st.id).await?; let mut station = Station::from(stations[0].clone()); station.id = st.id.clone(); station.name = st.name.clone(); @@ -72,22 +126,18 @@ impl Provider for Tunein { ) -> Result, Error> { let guide_id = category.clone(); let category = match category.to_lowercase().as_str() { - "by location" => Some(tunein::types::Category::ByLocation), - "by language" => Some(tunein::types::Category::ByLanguage), - "sports" => Some(tunein::types::Category::Sports), - "talk" => Some(tunein::types::Category::Talk), - "music" => Some(tunein::types::Category::Music), - "local radio" => Some(tunein::types::Category::LocalRadio), - "podcasts" => Some(tunein::types::Category::Podcasts), + "by location" => Some(Category::ByLocation), + "by language" => Some(Category::ByLanguage), + "sports" => Some(Category::Sports), + "talk" => Some(Category::Talk), + "music" => Some(Category::Music), + "local radio" => Some(Category::LocalRadio), + "podcasts" => Some(Category::Podcasts), _ => None, }; if category.is_none() { - let category_stations = self - .client - .browse_by_id(&guide_id) - .await - .map_err(|e| Error::msg(e.to_string()))?; + let category_stations = self.browse_stations(&guide_id).await?; let mut stations = vec![]; @@ -101,11 +151,7 @@ impl Provider for Tunein { return Ok(stations); } - let category_stations = self - .client - .browse(category) - .await - .map_err(|e| Error::msg(e.to_string()))?; + let category_stations = self.browse_categories(category).await?; let stations = category_stations .clone() @@ -128,11 +174,7 @@ impl Provider for Tunein { } async fn categories(&self, _offset: u32, _limit: u32) -> Result, Error> { - let categories = self - .client - .browse(None) - .await - .map_err(|e| Error::msg(e.to_string()))?; + let categories = self.browse_categories(None).await?; let categories = categories.into_iter().map(|x| x.text).collect(); Ok(categories) } @@ -141,46 +183,198 @@ impl Provider for Tunein { #[cfg(test)] mod tests { use super::*; + use mockito::{Matcher, Server, ServerGuard}; + + fn search_result_json(guide_id: &str, text: &str) -> serde_json::Value { + serde_json::json!({ + "element": "outline", + "type": "audio", + "text": text, + "URL": format!("http://opml.radiotime.com/Tune.ashx?id={}", guide_id), + "bitrate": "128", + "reliability": "98", + "guide_id": guide_id, + "subtext": "Now Playing", + "genre_id": "g52", + "formats": "mp3", + "item": "station", + "image": "https://cdn-profiles.tunein.com/s288303/images/logoq.png" + }) + } + + fn station_json(guide_id: &str, text: &str) -> serde_json::Value { + serde_json::json!({ + "element": "outline", + "type": "audio", + "text": text, + "URL": format!("http://opml.radiotime.com/Tune.ashx?id={}", guide_id), + "bitrate": "128", + "guide_id": guide_id, + "formats": "mp3" + }) + } + + async fn mock_provider() -> (ServerGuard, Tunein) { + let server = Server::new_async().await; + let provider = Tunein::with_base_url(server.url()); + (server, provider) + } #[tokio::test] pub async fn test_search() { - let provider = Tunein::new(); + let (mut server, provider) = mock_provider().await; let name = "alternativeradio"; + let body = serde_json::json!({ + "head": { "status": "200", "title": "Search Results: alternativeradio" }, + "body": [search_result_json("s288303", "AlternativeRadio.us")] + }); + let mock = server + .mock("GET", "/Search.ashx") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("query".into(), name.into()), + Matcher::UrlEncoded("render".into(), "json".into()), + ])) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .create_async() + .await; + let stations = provider.search(name.to_string()).await.unwrap(); - println!("Search: {:#?}", stations); - assert!(stations.len() > 0) + + mock.assert_async().await; + assert_eq!(stations.len(), 1); + assert_eq!(stations[0].id, "s288303"); + assert_eq!(stations[0].name, "AlternativeRadio.us"); } #[tokio::test] pub async fn test_get_station() { - let provider = Tunein::new(); - let name = "s288303".to_string(); - let station = provider.get_station(name).await.unwrap(); - println!("Station: {:#?}", station); - assert!(station.is_some()) + let (mut server, provider) = mock_provider().await; + let id = "s288303"; + let body = serde_json::json!({ + "head": { "status": "200" }, + "body": [{ + "element": "audio", + "bitrate": 128, + "is_direct": false, + "is_hls_advanced": "false", + "live_seek_stream": "false", + "media_type": "mp3", + "player_height": 0, + "player_width": 0, + "position": 0, + "reliability": 92, + "url": "http://stream.example.com/stream.mp3" + }] + }); + let mock = server + .mock("GET", "/Tune.ashx") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("id".into(), id.into()), + Matcher::UrlEncoded("render".into(), "json".into()), + ])) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .create_async() + .await; + + let station = provider.get_station(id.to_string()).await.unwrap(); + + mock.assert_async().await; + let station = station.unwrap(); + assert_eq!(station.id, id); + assert_eq!(station.stream_url, "http://stream.example.com/stream.mp3"); + assert_eq!(station.codec, "MP3"); } #[tokio::test] pub async fn test_browse() { - let provider = Tunein::new(); + let (mut server, provider) = mock_provider().await; + let body = serde_json::json!({ + "head": { "status": "200", "title": "Music" }, + "body": [{ + "element": "outline", + "text": "Top 40 & Pop Music", + "key": "c57942", + "guide_id": "c57942", + "children": [search_result_json("s288303", "AlternativeRadio.us")] + }] + }); + let mock = server + .mock("GET", "/Browse.ashx") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("c".into(), "music".into()), + Matcher::UrlEncoded("render".into(), "json".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(); - println!("Browse: {:#?}", stations); - assert!(stations.len() > 0) + + mock.assert_async().await; + // the category itself plus its child station + assert_eq!(stations.len(), 2); + assert_eq!(stations[0].name, "Top 40 & Pop Music"); + assert_eq!(stations[1].name, "AlternativeRadio.us"); } #[tokio::test] pub async fn test_browse_by_id() { - let provider = Tunein::new(); - let stations = provider.browse("c57942".to_string(), 0, 100).await.unwrap(); - println!("Browse by category id: {:#?}", stations); - assert!(stations.len() > 0) + let (mut server, provider) = mock_provider().await; + let id = "c57942"; + let body = serde_json::json!({ + "head": { "status": "200", "title": "Top 40 & Pop Music" }, + "body": [{ + "element": "outline", + "text": "Stations", + "guide_id": id, + "children": [station_json("s288303", "AlternativeRadio.us")] + }] + }); + let mock = server + .mock("GET", "/Browse.ashx") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("id".into(), id.into()), + Matcher::UrlEncoded("render".into(), "json".into()), + ])) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .create_async() + .await; + + let stations = provider.browse(id.to_string(), 0, 100).await.unwrap(); + + mock.assert_async().await; + // the parent outline plus its child station + assert_eq!(stations.len(), 2); + assert_eq!(stations[1].id, "s288303"); + assert_eq!(stations[1].name, "AlternativeRadio.us"); } #[tokio::test] pub async fn test_categories() { - let provider = Tunein::new(); + let (mut server, provider) = mock_provider().await; + let body = serde_json::json!({ + "head": { "status": "200", "title": "Browse" }, + "body": [ + { "element": "outline", "text": "Local Radio", "key": "local" }, + { "element": "outline", "text": "Music", "key": "music" }, + { "element": "outline", "text": "Talk", "key": "talk" } + ] + }); + let mock = server + .mock("GET", "/Browse.ashx") + .match_query(Matcher::UrlEncoded("render".into(), "json".into())) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .create_async() + .await; + let categories = provider.categories(0, 100).await.unwrap(); - println!("Categories: {:#?}", categories); - assert!(categories.len() > 0) + + mock.assert_async().await; + assert_eq!(categories, vec!["Local Radio", "Music", "Talk"]); } }