diff --git a/Cargo.lock b/Cargo.lock index a9d42e8..9839a92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,6 +68,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.16" @@ -1887,9 +1896,9 @@ checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" [[package]] name = "memchr" -version = "2.5.0" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "memoffset" @@ -2574,18 +2583,32 @@ dependencies = [ [[package]] name = "regex" -version = "1.7.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" -version = "0.6.28" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "remove_dir_all" @@ -3853,6 +3876,7 @@ dependencies = [ "prost", "radiobrowser", "ratatui", + "regex", "reqwest", "rodio", "rustfft", diff --git a/Cargo.toml b/Cargo.toml index 2535521..88abd52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ pls = "0.2.2" prost = "0.11.8" radiobrowser = { version = "0.6.1", features = ["default-rustls"], default-features = false } ratatui = "0.26.1" +regex = "1.11.1" reqwest = {version = "0.11.14", features = ["blocking", "rustls-tls"], default-features = false} rodio = {version = "0.16"} rustfft = "6.2.0" diff --git a/src/browse.rs b/src/browse.rs index 88a165d..972c1a2 100644 --- a/src/browse.rs +++ b/src/browse.rs @@ -1,84 +1,52 @@ -use std::str::FromStr; - use anyhow::Error; use owo_colors::OwoColorize; -use tunein::{types::Category, TuneInClient}; -pub async fn exec(category: Option<&str>) -> Result<(), Error> { - let client = TuneInClient::new(); +use crate::provider::radiobrowser::Radiobrowser; +use crate::provider::tunein::Tunein; +use crate::provider::Provider; + +pub async fn exec( + category: Option<&str>, + offset: u32, + limit: u32, + provider: &str, +) -> Result<(), Error> { + let provider: Box = match provider { + "tunein" => Box::new(Tunein::new()), + "radiobrowser" => Box::new(Radiobrowser::new().await), + _ => { + return Err(anyhow::anyhow!(format!( + "Unsupported provider '{}'", + provider + ))) + } + }; - if category.is_some() && Category::from_str(category.unwrap_or_default()).is_err() { - let id = category.unwrap_or_default(); - let results = client - .browse_by_id(id) - .await - .map_err(|e| Error::msg(e.to_string()))?; - for result in results { - println!("{}", result.text); - if let Some(children) = result.children { - for child in children { - match child.playing { + match category { + Some(category) => { + let results = provider.browse(category.to_string(), offset, limit).await?; + for result in results { + match result.id.is_empty() { + false => match result.playing { Some(playing) => println!( " {} | {} | id: {}", - child.text.magenta(), + result.name.magenta(), playing, - child.guide_id.unwrap() + result.id ), - None => { - if let Some(guide_id) = child.guide_id { - println!(" {} | {}", child.text.magenta(), guide_id); - } - } - } - } - } - } - return Ok(()); - } + None => println!(" {} | id: {}", result.name.magenta(), result.id), + }, - let results = match category { - Some(category) => match Category::from_str(category) { - Ok(category) => client - .browse(Some(category)) - .await - .map_err(|e| Error::msg(e.to_string()))?, - Err(_) => { - println!("Invalid category"); - return Ok(()); + true => println!("{}", result.name), + } } - }, - None => client - .browse(None) - .await - .map_err(|e| Error::msg(e.to_string()))?, - }; - - for result in results { - match result.guide_id { - Some(_) => println!( - "{} | id: {}", - result.text.magenta(), - result.guide_id.unwrap() - ), - None => println!("{}", result.text), } - if let Some(children) = result.children { - for child in children { - match child.playing { - Some(playing) => println!( - " {} | {} | id: {}", - child.text.magenta(), - playing, - child.guide_id.unwrap() - ), - None => println!( - " {} | id: {}", - child.text.magenta(), - child.guide_id.unwrap() - ), - } + None => { + let results = provider.categories(offset, limit).await?; + for result in results { + println!("{}", result.magenta()); } } - } + }; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 0a97c53..ef74566 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,9 @@ fn cli() -> Command<'static> { A simple CLI to listen to radio stations"#, ) + .arg( + arg!(-p --provider "The radio provider to use, can be 'tunein' or 'radiobrowser'. Default is 'tunein'").default_value("tunein") + ) .subcommand_required(true) .subcommand( Command::new("search") @@ -46,7 +49,9 @@ A simple CLI to listen to radio stations"#, .subcommand( Command::new("browse") .about("Browse radio stations") - .arg(arg!([category] "The category (category name or id) to browse")), + .arg(arg!([category] "The category (category name or id) to browse")) + .arg(arg!(--offset "The offset to start from").default_value("0")) + .arg(arg!(--limit "The number of results to show").default_value("100")), ) .subcommand( Command::new("server") @@ -70,7 +75,16 @@ async fn main() -> Result<(), Error> { } Some(("browse", args)) => { let category = args.value_of("category"); - browse::exec(category).await?; + let offset = args.value_of("offset").unwrap(); + let limit = args.value_of("limit").unwrap(); + let provider = matches.value_of("provider").unwrap(); + browse::exec( + category, + offset.parse::()?, + limit.parse::()?, + provider, + ) + .await?; } Some(("server", args)) => { let port = args.value_of("port").unwrap(); diff --git a/src/provider/mod.rs b/src/provider/mod.rs index fd2c70b..ba9c1ea 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -4,10 +4,25 @@ pub mod tunein; use crate::types::Station; use anyhow::Error; use async_trait::async_trait; +use regex::Regex; #[async_trait] pub trait Provider { async fn search(&self, name: String) -> Result, Error>; async fn get_station(&self, id: String) -> Result, Error>; - async fn browse(&self, category: String) -> Result, Error>; + async fn browse( + &self, + category: String, + offset: u32, + limit: u32, + ) -> Result, Error>; + async fn categories(&self, offset: u32, limit: u32) -> Result, Error>; +} + +pub fn is_valid_uuid(uuid: &str) -> bool { + let uuid_pattern = Regex::new( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + ).unwrap(); + + uuid_pattern.is_match(uuid) } diff --git a/src/provider/radiobrowser.rs b/src/provider/radiobrowser.rs index bde05bb..8ca4984 100644 --- a/src/provider/radiobrowser.rs +++ b/src/provider/radiobrowser.rs @@ -1,9 +1,9 @@ use crate::types::Station; -use super::Provider; +use super::{is_valid_uuid, Provider}; use anyhow::Error; use async_trait::async_trait; -use radiobrowser::RadioBrowserAPI; +use radiobrowser::{ApiStation, RadioBrowserAPI}; use std::process::exit; pub struct Radiobrowser { @@ -38,33 +38,79 @@ impl Provider for Radiobrowser { Ok(stations) } - async fn get_station(&self, name: String) -> Result, Error> { - let stations = self - .client - .get_stations() - .name(&name) - .name_exact(true) - .send() - .await - .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; - match stations.len() { - 0 => Ok(None), - _ => Ok(Some(Station::from(stations[0].clone()))), + async fn get_station(&self, name_or_uuid: String) -> Result, Error> { + match is_valid_uuid(&name_or_uuid) { + true => { + let servers = RadioBrowserAPI::get_default_servers() + .await + .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; + + if servers.is_empty() { + return Ok(None); + } + + let client = reqwest::Client::new(); + let url = format!( + "https://{}/json/stations/byuuid/{}", + servers[0], name_or_uuid + ); + let results = client + .get(&url) + .send() + .await? + .json::>() + .await?; + + Ok(results.into_iter().next().map(|x| Station::from(x))) + } + false => { + let stations = self + .client + .get_stations() + .name(&name_or_uuid) + .name_exact(true) + .send() + .await + .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; + match stations.len() { + 0 => Ok(None), + _ => Ok(Some(Station::from(stations[0].clone()))), + } + } } } - async fn browse(&self, category: String) -> Result, Error> { + async fn browse( + &self, + category: String, + offset: u32, + limit: u32, + ) -> Result, Error> { let stations = self .client .get_stations() .tag(&category) - .limit("100") + .offset(&format!("{}", offset)) + .limit(&format!("{}", limit)) .send() .await .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; let stations = stations.into_iter().map(|x| Station::from(x)).collect(); Ok(stations) } + + async fn categories(&self, offset: u32, limit: u32) -> Result, Error> { + let categories = self + .client + .get_tags() + .offset(&format!("{}", offset)) + .limit(&format!("{}", limit)) + .send() + .await + .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; + let categories = categories.into_iter().map(|x| x.name).collect(); + Ok(categories) + } } #[cfg(test)] @@ -87,14 +133,29 @@ mod tests { assert!(station.is_some()) } + #[tokio::test] + pub async fn test_get_station_by_uuid() { + let provider = Radiobrowser::new().await; + let name = "964da563-0601-11e8-ae97-52543be04c81".to_string(); + let station = provider.get_station(name).await.unwrap(); + assert!(station.is_some()) + } + #[tokio::test] pub async fn test_browse() { let provider = Radiobrowser::new().await; - let stations = provider.browse("music".to_string()).await.unwrap(); + let stations = provider.browse("music".to_string(), 0, 100).await.unwrap(); let stations = stations .into_iter() .map(|x| Station::from(x)) .collect::>(); assert!(stations.len() == 100) } + + #[tokio::test] + pub async fn test_categories() { + let provider = Radiobrowser::new().await; + let categories = provider.categories(0, 100).await.unwrap(); + assert!(categories.len() > 0) + } } diff --git a/src/provider/tunein.rs b/src/provider/tunein.rs index 6ecbfba..5cd05ce 100644 --- a/src/provider/tunein.rs +++ b/src/provider/tunein.rs @@ -41,9 +41,14 @@ impl Provider for Tunein { } } - async fn browse(&self, category: String) -> Result, Error> { + async fn browse( + &self, + category: String, + _offset: u32, + _limit: u32, + ) -> Result, Error> { let guide_id = category.clone(); - let category = match category.as_str() { + 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), @@ -64,8 +69,8 @@ impl Provider for Tunein { let mut stations = vec![]; for st in category_stations { - if let Some(children) = st.children { - stations = [stations, children].concat(); + if let Some(children) = st.clone().children { + stations = [stations, vec![Box::new(st.clone())], children].concat(); } } @@ -73,14 +78,40 @@ impl Provider for Tunein { return Ok(stations); } - let stations = self + let category_stations = self .client .browse(category) .await .map_err(|e| Error::msg(e.to_string()))?; - let stations = stations.into_iter().map(|x| Station::from(x)).collect(); - Ok(stations) + let stations = category_stations + .clone() + .into_iter() + .map(|x| Station::from(x)) + .collect::>(); + + let mut _stations = vec![]; + for st in category_stations { + if let Some(children) = st.children { + _stations = [_stations, children].concat(); + } + } + let _stations = _stations + .into_iter() + .map(|x| Station::from(x)) + .collect::>(); + + Ok([stations, _stations].concat()) + } + + 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 = categories.into_iter().map(|x| x.text).collect(); + Ok(categories) } } @@ -109,7 +140,7 @@ mod tests { #[tokio::test] pub async fn test_browse() { let provider = Tunein::new(); - let stations = provider.browse("music".to_string()).await.unwrap(); + let stations = provider.browse("music".to_string(), 0, 100).await.unwrap(); println!("Browse: {:#?}", stations); assert!(stations.len() > 0) } @@ -117,8 +148,16 @@ mod tests { #[tokio::test] pub async fn test_browse_by_id() { let provider = Tunein::new(); - let stations = provider.browse("c57942".to_string()).await.unwrap(); + let stations = provider.browse("c57942".to_string(), 0, 100).await.unwrap(); println!("Browse by category id: {:#?}", stations); assert!(stations.len() > 0) } + + #[tokio::test] + pub async fn test_categories() { + let provider = Tunein::new(); + let categories = provider.categories(0, 100).await.unwrap(); + println!("Categories: {:#?}", categories); + assert!(categories.len() > 0) + } } diff --git a/src/types.rs b/src/types.rs index 94215c7..d592d4d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -8,6 +8,7 @@ pub struct Station { pub codec: String, pub bitrate: u32, pub stream_url: String, + pub playing: Option, } impl From for Station { @@ -18,6 +19,7 @@ impl From for Station { codec: station.codec, bitrate: station.bitrate, stream_url: station.url_resolved, + playing: None, } } } @@ -34,6 +36,24 @@ impl From for Station { .unwrap_or_default(), codec: Default::default(), stream_url: Default::default(), + playing: None, + } + } +} + +impl From> for Station { + fn from(result: Box) -> Station { + Station { + id: result.guide_id.unwrap_or_default(), + name: result.text, + bitrate: result + .bitrate + .unwrap_or("0".to_string()) + .parse() + .unwrap_or_default(), + codec: Default::default(), + stream_url: Default::default(), + playing: None, } } } @@ -46,6 +66,7 @@ impl From for Station { bitrate: details.bitrate, stream_url: details.url, codec: details.media_type.to_uppercase(), + playing: None, } } } @@ -62,6 +83,7 @@ impl From for Station { .unwrap_or_default(), stream_url: Default::default(), codec: st.formats.unwrap_or_default().to_uppercase(), + playing: st.playing, } } } @@ -78,6 +100,7 @@ impl From> for Station { .unwrap_or_default(), stream_url: Default::default(), codec: st.formats.unwrap_or_default().to_uppercase(), + playing: st.playing, } } } @@ -90,6 +113,7 @@ impl From for Station { bitrate: 0, stream_url: Default::default(), codec: Default::default(), + playing: None, } } }