Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
radio rust tokio web-radio command-line-tool tui

feat(radiobrowser): get radio station by uuid

+30 -6
Cargo.lock
··· 69 69 ] 70 70 71 71 [[package]] 72 + name = "aho-corasick" 73 + version = "1.1.3" 74 + source = "registry+https://github.com/rust-lang/crates.io-index" 75 + checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 76 + dependencies = [ 77 + "memchr", 78 + ] 79 + 80 + [[package]] 72 81 name = "allocator-api2" 73 82 version = "0.2.16" 74 83 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1887 1896 1888 1897 [[package]] 1889 1898 name = "memchr" 1890 - version = "2.5.0" 1899 + version = "2.7.4" 1891 1900 source = "registry+https://github.com/rust-lang/crates.io-index" 1892 - checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 1901 + checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 1893 1902 1894 1903 [[package]] 1895 1904 name = "memoffset" ··· 2574 2583 2575 2584 [[package]] 2576 2585 name = "regex" 2577 - version = "1.7.1" 2586 + version = "1.11.1" 2578 2587 source = "registry+https://github.com/rust-lang/crates.io-index" 2579 - checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" 2588 + checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 2580 2589 dependencies = [ 2590 + "aho-corasick", 2591 + "memchr", 2592 + "regex-automata", 2593 + "regex-syntax", 2594 + ] 2595 + 2596 + [[package]] 2597 + name = "regex-automata" 2598 + version = "0.4.9" 2599 + source = "registry+https://github.com/rust-lang/crates.io-index" 2600 + checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 2601 + dependencies = [ 2602 + "aho-corasick", 2603 + "memchr", 2581 2604 "regex-syntax", 2582 2605 ] 2583 2606 2584 2607 [[package]] 2585 2608 name = "regex-syntax" 2586 - version = "0.6.28" 2609 + version = "0.8.5" 2587 2610 source = "registry+https://github.com/rust-lang/crates.io-index" 2588 - checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" 2611 + checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 2589 2612 2590 2613 [[package]] 2591 2614 name = "remove_dir_all" ··· 3853 3876 "prost", 3854 3877 "radiobrowser", 3855 3878 "ratatui", 3879 + "regex", 3856 3880 "reqwest", 3857 3881 "rodio", 3858 3882 "rustfft",
+1
Cargo.toml
··· 39 39 prost = "0.11.8" 40 40 radiobrowser = { version = "0.6.1", features = ["default-rustls"], default-features = false } 41 41 ratatui = "0.26.1" 42 + regex = "1.11.1" 42 43 reqwest = {version = "0.11.14", features = ["blocking", "rustls-tls"], default-features = false} 43 44 rodio = {version = "0.16"} 44 45 rustfft = "6.2.0"
+37 -69
src/browse.rs
··· 1 - use std::str::FromStr; 2 - 3 1 use anyhow::Error; 4 2 use owo_colors::OwoColorize; 5 - use tunein::{types::Category, TuneInClient}; 3 + 4 + use crate::provider::radiobrowser::Radiobrowser; 5 + use crate::provider::tunein::Tunein; 6 + use crate::provider::Provider; 6 7 7 - pub async fn exec(category: Option<&str>) -> Result<(), Error> { 8 - let client = TuneInClient::new(); 8 + pub async fn exec( 9 + category: Option<&str>, 10 + offset: u32, 11 + limit: u32, 12 + provider: &str, 13 + ) -> Result<(), Error> { 14 + let provider: Box<dyn Provider> = match provider { 15 + "tunein" => Box::new(Tunein::new()), 16 + "radiobrowser" => Box::new(Radiobrowser::new().await), 17 + _ => { 18 + return Err(anyhow::anyhow!(format!( 19 + "Unsupported provider '{}'", 20 + provider 21 + ))) 22 + } 23 + }; 9 24 10 - if category.is_some() && Category::from_str(category.unwrap_or_default()).is_err() { 11 - let id = category.unwrap_or_default(); 12 - let results = client 13 - .browse_by_id(id) 14 - .await 15 - .map_err(|e| Error::msg(e.to_string()))?; 16 - for result in results { 17 - println!("{}", result.text); 18 - if let Some(children) = result.children { 19 - for child in children { 20 - match child.playing { 25 + match category { 26 + Some(category) => { 27 + let results = provider.browse(category.to_string(), offset, limit).await?; 28 + for result in results { 29 + match result.id.is_empty() { 30 + false => match result.playing { 21 31 Some(playing) => println!( 22 32 " {} | {} | id: {}", 23 - child.text.magenta(), 33 + result.name.magenta(), 24 34 playing, 25 - child.guide_id.unwrap() 35 + result.id 26 36 ), 27 - None => { 28 - if let Some(guide_id) = child.guide_id { 29 - println!(" {} | {}", child.text.magenta(), guide_id); 30 - } 31 - } 32 - } 33 - } 34 - } 35 - } 36 - return Ok(()); 37 - } 37 + None => println!(" {} | id: {}", result.name.magenta(), result.id), 38 + }, 38 39 39 - let results = match category { 40 - Some(category) => match Category::from_str(category) { 41 - Ok(category) => client 42 - .browse(Some(category)) 43 - .await 44 - .map_err(|e| Error::msg(e.to_string()))?, 45 - Err(_) => { 46 - println!("Invalid category"); 47 - return Ok(()); 40 + true => println!("{}", result.name), 41 + } 48 42 } 49 - }, 50 - None => client 51 - .browse(None) 52 - .await 53 - .map_err(|e| Error::msg(e.to_string()))?, 54 - }; 55 - 56 - for result in results { 57 - match result.guide_id { 58 - Some(_) => println!( 59 - "{} | id: {}", 60 - result.text.magenta(), 61 - result.guide_id.unwrap() 62 - ), 63 - None => println!("{}", result.text), 64 43 } 65 - if let Some(children) = result.children { 66 - for child in children { 67 - match child.playing { 68 - Some(playing) => println!( 69 - " {} | {} | id: {}", 70 - child.text.magenta(), 71 - playing, 72 - child.guide_id.unwrap() 73 - ), 74 - None => println!( 75 - " {} | id: {}", 76 - child.text.magenta(), 77 - child.guide_id.unwrap() 78 - ), 79 - } 44 + None => { 45 + let results = provider.categories(offset, limit).await?; 46 + for result in results { 47 + println!("{}", result.magenta()); 80 48 } 81 49 } 82 - } 50 + }; 83 51 Ok(()) 84 52 }
+16 -2
src/main.rs
··· 32 32 33 33 A simple CLI to listen to radio stations"#, 34 34 ) 35 + .arg( 36 + arg!(-p --provider "The radio provider to use, can be 'tunein' or 'radiobrowser'. Default is 'tunein'").default_value("tunein") 37 + ) 35 38 .subcommand_required(true) 36 39 .subcommand( 37 40 Command::new("search") ··· 46 49 .subcommand( 47 50 Command::new("browse") 48 51 .about("Browse radio stations") 49 - .arg(arg!([category] "The category (category name or id) to browse")), 52 + .arg(arg!([category] "The category (category name or id) to browse")) 53 + .arg(arg!(--offset "The offset to start from").default_value("0")) 54 + .arg(arg!(--limit "The number of results to show").default_value("100")), 50 55 ) 51 56 .subcommand( 52 57 Command::new("server") ··· 70 75 } 71 76 Some(("browse", args)) => { 72 77 let category = args.value_of("category"); 73 - browse::exec(category).await?; 78 + let offset = args.value_of("offset").unwrap(); 79 + let limit = args.value_of("limit").unwrap(); 80 + let provider = matches.value_of("provider").unwrap(); 81 + browse::exec( 82 + category, 83 + offset.parse::<u32>()?, 84 + limit.parse::<u32>()?, 85 + provider, 86 + ) 87 + .await?; 74 88 } 75 89 Some(("server", args)) => { 76 90 let port = args.value_of("port").unwrap();
+16 -1
src/provider/mod.rs
··· 4 4 use crate::types::Station; 5 5 use anyhow::Error; 6 6 use async_trait::async_trait; 7 + use regex::Regex; 7 8 8 9 #[async_trait] 9 10 pub trait Provider { 10 11 async fn search(&self, name: String) -> Result<Vec<Station>, Error>; 11 12 async fn get_station(&self, id: String) -> Result<Option<Station>, Error>; 12 - async fn browse(&self, category: String) -> Result<Vec<Station>, Error>; 13 + async fn browse( 14 + &self, 15 + category: String, 16 + offset: u32, 17 + limit: u32, 18 + ) -> Result<Vec<Station>, Error>; 19 + async fn categories(&self, offset: u32, limit: u32) -> Result<Vec<String>, Error>; 20 + } 21 + 22 + pub fn is_valid_uuid(uuid: &str) -> bool { 23 + let uuid_pattern = Regex::new( 24 + 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}$" 25 + ).unwrap(); 26 + 27 + uuid_pattern.is_match(uuid) 13 28 }
+78 -17
src/provider/radiobrowser.rs
··· 1 1 use crate::types::Station; 2 2 3 - use super::Provider; 3 + use super::{is_valid_uuid, Provider}; 4 4 use anyhow::Error; 5 5 use async_trait::async_trait; 6 - use radiobrowser::RadioBrowserAPI; 6 + use radiobrowser::{ApiStation, RadioBrowserAPI}; 7 7 use std::process::exit; 8 8 9 9 pub struct Radiobrowser { ··· 38 38 Ok(stations) 39 39 } 40 40 41 - async fn get_station(&self, name: String) -> Result<Option<Station>, Error> { 41 + async fn get_station(&self, name_or_uuid: String) -> Result<Option<Station>, Error> { 42 + match is_valid_uuid(&name_or_uuid) { 43 + true => { 44 + let servers = RadioBrowserAPI::get_default_servers() 45 + .await 46 + .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; 47 + 48 + if servers.is_empty() { 49 + return Ok(None); 50 + } 51 + 52 + let client = reqwest::Client::new(); 53 + let url = format!( 54 + "https://{}/json/stations/byuuid/{}", 55 + servers[0], name_or_uuid 56 + ); 57 + let results = client 58 + .get(&url) 59 + .send() 60 + .await? 61 + .json::<Vec<ApiStation>>() 62 + .await?; 63 + 64 + Ok(results.into_iter().next().map(|x| Station::from(x))) 65 + } 66 + false => { 67 + let stations = self 68 + .client 69 + .get_stations() 70 + .name(&name_or_uuid) 71 + .name_exact(true) 72 + .send() 73 + .await 74 + .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; 75 + match stations.len() { 76 + 0 => Ok(None), 77 + _ => Ok(Some(Station::from(stations[0].clone()))), 78 + } 79 + } 80 + } 81 + } 82 + 83 + async fn browse( 84 + &self, 85 + category: String, 86 + offset: u32, 87 + limit: u32, 88 + ) -> Result<Vec<Station>, Error> { 42 89 let stations = self 43 90 .client 44 91 .get_stations() 45 - .name(&name) 46 - .name_exact(true) 92 + .tag(&category) 93 + .offset(&format!("{}", offset)) 94 + .limit(&format!("{}", limit)) 47 95 .send() 48 96 .await 49 97 .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; 50 - match stations.len() { 51 - 0 => Ok(None), 52 - _ => Ok(Some(Station::from(stations[0].clone()))), 53 - } 98 + let stations = stations.into_iter().map(|x| Station::from(x)).collect(); 99 + Ok(stations) 54 100 } 55 101 56 - async fn browse(&self, category: String) -> Result<Vec<Station>, Error> { 57 - let stations = self 102 + async fn categories(&self, offset: u32, limit: u32) -> Result<Vec<String>, Error> { 103 + let categories = self 58 104 .client 59 - .get_stations() 60 - .tag(&category) 61 - .limit("100") 105 + .get_tags() 106 + .offset(&format!("{}", offset)) 107 + .limit(&format!("{}", limit)) 62 108 .send() 63 109 .await 64 110 .map_err(|e| anyhow::anyhow!(format!("{}", e)))?; 65 - let stations = stations.into_iter().map(|x| Station::from(x)).collect(); 66 - Ok(stations) 111 + let categories = categories.into_iter().map(|x| x.name).collect(); 112 + Ok(categories) 67 113 } 68 114 } 69 115 ··· 88 134 } 89 135 90 136 #[tokio::test] 137 + pub async fn test_get_station_by_uuid() { 138 + let provider = Radiobrowser::new().await; 139 + let name = "964da563-0601-11e8-ae97-52543be04c81".to_string(); 140 + let station = provider.get_station(name).await.unwrap(); 141 + assert!(station.is_some()) 142 + } 143 + 144 + #[tokio::test] 91 145 pub async fn test_browse() { 92 146 let provider = Radiobrowser::new().await; 93 - let stations = provider.browse("music".to_string()).await.unwrap(); 147 + let stations = provider.browse("music".to_string(), 0, 100).await.unwrap(); 94 148 let stations = stations 95 149 .into_iter() 96 150 .map(|x| Station::from(x)) 97 151 .collect::<Vec<Station>>(); 98 152 assert!(stations.len() == 100) 153 + } 154 + 155 + #[tokio::test] 156 + pub async fn test_categories() { 157 + let provider = Radiobrowser::new().await; 158 + let categories = provider.categories(0, 100).await.unwrap(); 159 + assert!(categories.len() > 0) 99 160 } 100 161 }
+48 -9
src/provider/tunein.rs
··· 41 41 } 42 42 } 43 43 44 - async fn browse(&self, category: String) -> Result<Vec<Station>, Error> { 44 + async fn browse( 45 + &self, 46 + category: String, 47 + _offset: u32, 48 + _limit: u32, 49 + ) -> Result<Vec<Station>, Error> { 45 50 let guide_id = category.clone(); 46 - let category = match category.as_str() { 51 + let category = match category.to_lowercase().as_str() { 47 52 "by location" => Some(tunein::types::Category::ByLocation), 48 53 "by language" => Some(tunein::types::Category::ByLanguage), 49 54 "sports" => Some(tunein::types::Category::Sports), ··· 64 69 let mut stations = vec![]; 65 70 66 71 for st in category_stations { 67 - if let Some(children) = st.children { 68 - stations = [stations, children].concat(); 72 + if let Some(children) = st.clone().children { 73 + stations = [stations, vec![Box::new(st.clone())], children].concat(); 69 74 } 70 75 } 71 76 ··· 73 78 return Ok(stations); 74 79 } 75 80 76 - let stations = self 81 + let category_stations = self 77 82 .client 78 83 .browse(category) 79 84 .await 80 85 .map_err(|e| Error::msg(e.to_string()))?; 81 86 82 - let stations = stations.into_iter().map(|x| Station::from(x)).collect(); 83 - Ok(stations) 87 + let stations = category_stations 88 + .clone() 89 + .into_iter() 90 + .map(|x| Station::from(x)) 91 + .collect::<Vec<Station>>(); 92 + 93 + let mut _stations = vec![]; 94 + for st in category_stations { 95 + if let Some(children) = st.children { 96 + _stations = [_stations, children].concat(); 97 + } 98 + } 99 + let _stations = _stations 100 + .into_iter() 101 + .map(|x| Station::from(x)) 102 + .collect::<Vec<Station>>(); 103 + 104 + Ok([stations, _stations].concat()) 105 + } 106 + 107 + async fn categories(&self, _offset: u32, _limit: u32) -> Result<Vec<String>, Error> { 108 + let categories = self 109 + .client 110 + .browse(None) 111 + .await 112 + .map_err(|e| Error::msg(e.to_string()))?; 113 + let categories = categories.into_iter().map(|x| x.text).collect(); 114 + Ok(categories) 84 115 } 85 116 } 86 117 ··· 109 140 #[tokio::test] 110 141 pub async fn test_browse() { 111 142 let provider = Tunein::new(); 112 - let stations = provider.browse("music".to_string()).await.unwrap(); 143 + let stations = provider.browse("music".to_string(), 0, 100).await.unwrap(); 113 144 println!("Browse: {:#?}", stations); 114 145 assert!(stations.len() > 0) 115 146 } ··· 117 148 #[tokio::test] 118 149 pub async fn test_browse_by_id() { 119 150 let provider = Tunein::new(); 120 - let stations = provider.browse("c57942".to_string()).await.unwrap(); 151 + let stations = provider.browse("c57942".to_string(), 0, 100).await.unwrap(); 121 152 println!("Browse by category id: {:#?}", stations); 122 153 assert!(stations.len() > 0) 154 + } 155 + 156 + #[tokio::test] 157 + pub async fn test_categories() { 158 + let provider = Tunein::new(); 159 + let categories = provider.categories(0, 100).await.unwrap(); 160 + println!("Categories: {:#?}", categories); 161 + assert!(categories.len() > 0) 123 162 } 124 163 }
+24
src/types.rs
··· 8 8 pub codec: String, 9 9 pub bitrate: u32, 10 10 pub stream_url: String, 11 + pub playing: Option<String>, 11 12 } 12 13 13 14 impl From<ApiStation> for Station { ··· 18 19 codec: station.codec, 19 20 bitrate: station.bitrate, 20 21 stream_url: station.url_resolved, 22 + playing: None, 21 23 } 22 24 } 23 25 } ··· 34 36 .unwrap_or_default(), 35 37 codec: Default::default(), 36 38 stream_url: Default::default(), 39 + playing: None, 40 + } 41 + } 42 + } 43 + 44 + impl From<Box<SearchResult>> for Station { 45 + fn from(result: Box<SearchResult>) -> Station { 46 + Station { 47 + id: result.guide_id.unwrap_or_default(), 48 + name: result.text, 49 + bitrate: result 50 + .bitrate 51 + .unwrap_or("0".to_string()) 52 + .parse() 53 + .unwrap_or_default(), 54 + codec: Default::default(), 55 + stream_url: Default::default(), 56 + playing: None, 37 57 } 38 58 } 39 59 } ··· 46 66 bitrate: details.bitrate, 47 67 stream_url: details.url, 48 68 codec: details.media_type.to_uppercase(), 69 + playing: None, 49 70 } 50 71 } 51 72 } ··· 62 83 .unwrap_or_default(), 63 84 stream_url: Default::default(), 64 85 codec: st.formats.unwrap_or_default().to_uppercase(), 86 + playing: st.playing, 65 87 } 66 88 } 67 89 } ··· 78 100 .unwrap_or_default(), 79 101 stream_url: Default::default(), 80 102 codec: st.formats.unwrap_or_default().to_uppercase(), 103 + playing: st.playing, 81 104 } 82 105 } 83 106 } ··· 90 113 bitrate: 0, 91 114 stream_url: Default::default(), 92 115 codec: Default::default(), 116 + playing: None, 93 117 } 94 118 } 95 119 }