+30
-6
Cargo.lock
+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
+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
+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
+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
+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
+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
+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
+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
}