use std::collections::HashMap;
use std::num::NonZeroUsize;
use http::HeaderMap;
use crate::parse::{ParseResult, parse_delimited_string};
pub const ICY_METADATA_HEADER: &str = "Icy-MetaData";
pub fn add_icy_metadata_header(header_map: &mut HeaderMap) {
header_map.append(
ICY_METADATA_HEADER,
"1".parse().expect("valid header value"),
);
}
pub trait RequestIcyMetadata {
fn request_icy_metadata(self) -> Self;
}
#[cfg(feature = "reqwest")]
impl RequestIcyMetadata for reqwest::ClientBuilder {
fn request_icy_metadata(self) -> Self {
let mut header_map = HeaderMap::new();
add_icy_metadata_header(&mut header_map);
self.default_headers(header_map)
}
}
#[cfg(feature = "reqwest")]
impl RequestIcyMetadata for reqwest::RequestBuilder {
fn request_icy_metadata(self) -> Self {
let mut header_map = HeaderMap::new();
add_icy_metadata_header(&mut header_map);
self.headers(header_map)
}
}
#[derive(Clone, Debug, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct IcyHeaders {
bitrate: Option<u32>,
sample_rate: Option<u32>,
genre: Vec<String>,
name: Option<String>,
station_url: Option<String>,
description: Option<String>,
public: Option<bool>,
notice1: Option<String>,
notice2: Option<String>,
loudness: Option<f32>,
logo_url: Option<String>,
main_stream_url: Option<String>,
version: Option<u32>,
index_metadata: Option<bool>,
country_code: Option<String>,
country_subdivision_code: Option<String>,
language_codes: Vec<String>,
geo_lat_long: Option<[f32; 2]>,
do_not_index: Option<bool>,
metadata_interval: Option<NonZeroUsize>,
audio_info: Option<IcyAudioInfo>,
}
fn find_header(search: &[&str], headers: &HeaderMap) -> Option<String> {
for header in search {
if let Some(val) = headers.get(*header) {
return val.to_str().ok().map(|s| s.trim_ascii().to_string());
}
}
None
}
fn str_to_bool(val: &str) -> bool {
val == "1" || val.eq_ignore_ascii_case("true") || val.eq_ignore_ascii_case("yes")
}
fn comma_separated(val: String) -> Vec<String> {
val.split(',').map(|s| s.trim_ascii().to_string()).collect()
}
impl IcyHeaders {
pub fn parse_from_headers(headers: &HeaderMap) -> Self {
Self {
bitrate: find_header(&["ice-bitrate", "icy-br", "x-audiocast-bitrate"], headers)
.and_then(|val| comma_separated(val).first()?.parse().ok()),
sample_rate: find_header(
&["ice-samplerate", "icy-sr", "x-audiocast-samplerate"],
headers,
)
.and_then(|val| val.parse().ok()),
genre: find_header(&["ice-genre", "icy-genre", "x-audiocast-genre"], headers)
.map(comma_separated)
.unwrap_or_default(),
name: find_header(&["ice-name", "icy-name", "x-audiocast-name"], headers),
description: find_header(
&[
"ice-description",
"icy-description",
"x-audiocast-description",
],
headers,
),
station_url: find_header(&["ice-url", "icy-url", "x-audiocast-url"], headers),
notice1: find_header(
&["ice-notice1", "icy-notice1", "x-audiocast-notice1"],
headers,
),
notice2: find_header(
&["ice-notice2", "icy-notice2", "x-audiocast-notice2"],
headers,
),
loudness: find_header(&["X-Loudness"], headers).and_then(|val| val.parse().ok()),
public: find_header(
&["ice-public", "icy-pub", "icy-public", "x-audiocast-public"],
headers,
)
.as_deref()
.map(str_to_bool),
logo_url: find_header(&["icy-logo"], headers),
main_stream_url: find_header(&["icy-main-stream-url"], headers),
version: find_header(&["icy-version"], headers).and_then(|h| h.parse().ok()),
index_metadata: find_header(&["icy-index-metadata"], headers)
.as_deref()
.map(str_to_bool),
country_code: find_header(&["icy-country-code"], headers),
country_subdivision_code: find_header(&["icy-country-subdivision-code"], headers),
language_codes: find_header(&["icy-language-codes", "icy-language-code"], headers)
.map(comma_separated)
.unwrap_or_default(),
geo_lat_long: find_header(&["icy-geo-lat-long"], headers).and_then(|h| {
if let [lat, long] = &comma_separated(h)[..] {
if let (Ok(lat), Ok(long)) = (lat.parse(), long.parse()) {
return Some([lat, long]);
}
}
None
}),
do_not_index: find_header(&["icy-do-not-index"], headers)
.as_deref()
.map(str_to_bool),
metadata_interval: find_header(&["icy-metaint"], headers)
.and_then(|val| NonZeroUsize::new(val.parse().ok()?)),
audio_info: find_header(&["ice-audio-info", "icy-audio-info"], headers).map(|val| {
let ParseResult { map, .. } = parse_delimited_string(&val);
IcyAudioInfo::parse_from_map(map)
}),
}
}
fn audio_info_prop<F, T>(&self, f: F) -> Option<T>
where
F: Fn(&IcyAudioInfo) -> Option<T>,
{
self.audio_info.as_ref().and_then(f)
}
pub fn bitrate(&self) -> Option<u32> {
self.bitrate.or(self.audio_info_prop(|a| a.bitrate))
}
pub fn sample_rate(&self) -> Option<u32> {
self.sample_rate.or(self.audio_info_prop(|a| a.sample_rate))
}
pub fn genre(&self) -> &[String] {
&self.genre
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn station_url(&self) -> Option<&str> {
self.station_url.as_deref()
}
pub fn notice1(&self) -> Option<&str> {
self.notice1.as_deref()
}
pub fn notice2(&self) -> Option<&str> {
self.notice2.as_deref()
}
pub fn loudness(&self) -> Option<f32> {
self.loudness
}
pub fn public(&self) -> Option<bool> {
self.public
}
pub fn channels(&self) -> Option<u16> {
self.audio_info_prop(|a| a.channels)
}
pub fn quality(&self) -> Option<String> {
self.audio_info_prop(|a| a.quality.clone())
}
pub fn logo_url(&self) -> Option<&str> {
self.logo_url.as_deref()
}
pub fn main_stream_url(&self) -> Option<&str> {
self.main_stream_url.as_deref()
}
pub fn version(&self) -> Option<u32> {
self.version
}
pub fn index_metadata(&self) -> Option<bool> {
self.index_metadata
}
pub fn country_code(&self) -> Option<&str> {
self.country_code.as_deref()
}
pub fn country_subdivision_code(&self) -> Option<&str> {
self.country_subdivision_code.as_deref()
}
pub fn language_codes(&self) -> &[String] {
&self.language_codes
}
pub fn geo_lat_long(&self) -> Option<[f32; 2]> {
self.geo_lat_long
}
pub fn do_not_index(&self) -> Option<bool> {
self.do_not_index
}
pub fn custom(&self) -> HashMap<String, String> {
self.audio_info_prop(|a| Some(a.custom.clone()))
.unwrap_or_default()
}
pub fn metadata_interval(&self) -> Option<NonZeroUsize> {
self.metadata_interval
}
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
struct IcyAudioInfo {
sample_rate: Option<u32>,
bitrate: Option<u32>,
channels: Option<u16>,
quality: Option<String>,
custom: HashMap<String, String>,
}
impl IcyAudioInfo {
fn parse_from_map(map: HashMap<&str, &str>) -> Self {
let mut info = Self {
sample_rate: None,
bitrate: None,
channels: None,
quality: None,
custom: HashMap::new(),
};
for (key, value) in map {
let key = key.trim_ascii();
let value = value.trim_ascii();
let Ok(key) = urlencoding::decode(key) else {
continue;
};
let Ok(value) = urlencoding::decode(value) else {
continue;
};
match key.to_ascii_lowercase().as_str() {
"icy-samplerate" | "ice-samplerate" | "samplerate" => {
info.sample_rate = value.parse().ok();
}
"icy-bitrate" | "ice-bitrate" | "bitrate" => {
info.bitrate = value.parse().ok();
}
"icy-channels" | "ice-channels" | "channels" => {
info.channels = value.parse().ok();
}
"icy-quality" | "ice-quality" | "quality" => {
info.quality = value.into_owned().into();
}
_ => {
info.custom.insert(key.to_string(), value.to_string());
}
}
}
info
}
}