feat: Add playlist and genre separation to MQTT playback state and discovery
This commit is contained in:
@@ -13,6 +13,7 @@ pub struct MqttPlaybackState {
|
||||
pub media_type: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub playlist: String,
|
||||
pub genre: String,
|
||||
pub stream_title: String,
|
||||
}
|
||||
@@ -25,6 +26,7 @@ impl MqttPlaybackState {
|
||||
media_type: String::new(),
|
||||
title: String::new(),
|
||||
artist: String::new(),
|
||||
playlist: String::new(),
|
||||
genre: String::new(),
|
||||
stream_title: String::new(),
|
||||
}
|
||||
@@ -39,6 +41,7 @@ impl MqttPlaybackState {
|
||||
media_type: st.media_type.clone(),
|
||||
title: st.title.clone(),
|
||||
artist: st.artist.clone(),
|
||||
playlist: st.playlist.clone(),
|
||||
genre: st.genre.clone(),
|
||||
stream_title: st.stream_title.clone(),
|
||||
}
|
||||
@@ -58,8 +61,24 @@ impl MqttService {
|
||||
|
||||
async fn run_mqtt_loop(config: AppConfig, player: Arc<PlayerController>) {
|
||||
let mqtt_cfg = &config.mqtt;
|
||||
let device_id = &config.jellyfin.device_id;
|
||||
let device_name = &config.jellyfin.device_name;
|
||||
let fallback_hostname = crate::config::get_system_hostname();
|
||||
let device_id = if config.jellyfin.device_id.is_empty()
|
||||
|| config.jellyfin.device_id == "htpc-kiosk-01"
|
||||
|| config.jellyfin.device_id == "htpc-living-room-01"
|
||||
{
|
||||
&fallback_hostname
|
||||
} else {
|
||||
&config.jellyfin.device_id
|
||||
};
|
||||
|
||||
let device_name = if config.jellyfin.device_name.is_empty()
|
||||
|| config.jellyfin.device_name == "HTPC Kiosk"
|
||||
|| config.jellyfin.device_name == "HTPC Living Room"
|
||||
{
|
||||
device_id
|
||||
} else {
|
||||
&config.jellyfin.device_name
|
||||
};
|
||||
|
||||
let client_id = mqtt_cfg.client_id.clone().unwrap_or_else(|| {
|
||||
format!("{}-{}", mqtt_cfg.topic_prefix, device_id)
|
||||
@@ -196,6 +215,12 @@ pub async fn publish_homeassistant_discovery(
|
||||
value_template: "{{ value_json.artist }}",
|
||||
icon: "mdi:account-music",
|
||||
},
|
||||
SensorConfig {
|
||||
object_id: "playlist",
|
||||
name: "Playlist",
|
||||
value_template: "{{ value_json.playlist }}",
|
||||
icon: "mdi:playlist-music",
|
||||
},
|
||||
SensorConfig {
|
||||
object_id: "genre",
|
||||
name: "Genre",
|
||||
@@ -385,6 +410,7 @@ mod tests {
|
||||
volume: 100.0,
|
||||
is_muted: false,
|
||||
error_message: None,
|
||||
..PlayerStatus::default()
|
||||
};
|
||||
|
||||
let mqtt_state = MqttPlaybackState::from_status(&st);
|
||||
@@ -392,6 +418,7 @@ mod tests {
|
||||
assert_eq!(mqtt_state.media_type, "");
|
||||
assert_eq!(mqtt_state.title, "");
|
||||
assert_eq!(mqtt_state.artist, "");
|
||||
assert_eq!(mqtt_state.playlist, "");
|
||||
assert_eq!(mqtt_state.genre, "");
|
||||
assert_eq!(mqtt_state.stream_title, "");
|
||||
}
|
||||
@@ -403,6 +430,7 @@ mod tests {
|
||||
media_type: "music".to_string(),
|
||||
title: "Bohemian Rhapsody".to_string(),
|
||||
artist: "Queen".to_string(),
|
||||
playlist: "Favorites".to_string(),
|
||||
genre: "Rock".to_string(),
|
||||
stream_title: "".to_string(),
|
||||
time_pos: 42.0,
|
||||
@@ -410,6 +438,7 @@ mod tests {
|
||||
volume: 85.0,
|
||||
is_muted: false,
|
||||
error_message: None,
|
||||
..PlayerStatus::default()
|
||||
};
|
||||
|
||||
let mqtt_state = MqttPlaybackState::from_status(&st);
|
||||
@@ -417,6 +446,7 @@ mod tests {
|
||||
assert_eq!(mqtt_state.media_type, "music");
|
||||
assert_eq!(mqtt_state.title, "Bohemian Rhapsody");
|
||||
assert_eq!(mqtt_state.artist, "Queen");
|
||||
assert_eq!(mqtt_state.playlist, "Favorites");
|
||||
assert_eq!(mqtt_state.genre, "Rock");
|
||||
assert_eq!(mqtt_state.stream_title, "");
|
||||
}
|
||||
@@ -428,6 +458,7 @@ mod tests {
|
||||
media_type: "stream".to_string(),
|
||||
title: "Radio Veronica".to_string(),
|
||||
artist: "Pink Floyd".to_string(),
|
||||
playlist: "Radio".to_string(),
|
||||
genre: "Classic Rock".to_string(),
|
||||
stream_title: "Pink Floyd - Comfortably Numb".to_string(),
|
||||
time_pos: 120.0,
|
||||
@@ -435,6 +466,7 @@ mod tests {
|
||||
volume: 90.0,
|
||||
is_muted: false,
|
||||
error_message: None,
|
||||
..PlayerStatus::default()
|
||||
};
|
||||
|
||||
let mqtt_state = MqttPlaybackState::from_status(&st);
|
||||
@@ -442,6 +474,7 @@ mod tests {
|
||||
assert_eq!(mqtt_state.media_type, "stream");
|
||||
assert_eq!(mqtt_state.title, "Radio Veronica");
|
||||
assert_eq!(mqtt_state.artist, "Pink Floyd");
|
||||
assert_eq!(mqtt_state.playlist, "Radio");
|
||||
assert_eq!(mqtt_state.genre, "Classic Rock");
|
||||
assert_eq!(mqtt_state.stream_title, "Pink Floyd - Comfortably Numb");
|
||||
}
|
||||
@@ -460,6 +493,7 @@ mod tests {
|
||||
volume: 100.0,
|
||||
is_muted: false,
|
||||
error_message: None,
|
||||
..PlayerStatus::default()
|
||||
};
|
||||
|
||||
let mqtt_state = MqttPlaybackState::from_status(&st);
|
||||
|
||||
+330
-16
@@ -19,12 +19,16 @@ pub struct PlayerStatus {
|
||||
pub media_type: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub playlist: String,
|
||||
pub genre: String,
|
||||
pub stream_title: String,
|
||||
pub time_pos: f64,
|
||||
pub duration: f64,
|
||||
pub volume: f64,
|
||||
pub is_muted: bool,
|
||||
pub is_loading: bool,
|
||||
pub is_buffering: bool,
|
||||
pub buffering_percent: Option<i64>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -35,12 +39,16 @@ impl Default for PlayerStatus {
|
||||
media_type: String::new(),
|
||||
title: String::new(),
|
||||
artist: String::new(),
|
||||
playlist: String::new(),
|
||||
genre: String::new(),
|
||||
stream_title: String::new(),
|
||||
time_pos: 0.0,
|
||||
duration: 0.0,
|
||||
volume: 100.0,
|
||||
is_muted: false,
|
||||
is_loading: false,
|
||||
is_buffering: false,
|
||||
buffering_percent: None,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
@@ -282,9 +290,35 @@ unsafe extern "C" fn on_mpv_render_update(ctx_ptr: *mut std::ffi::c_void) {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_url_or_query_string(s: &str) -> bool {
|
||||
s.starts_with("http://")
|
||||
|| s.starts_with("https://")
|
||||
|| s.starts_with("stream?")
|
||||
|| s.contains("api_key=")
|
||||
|| s.contains("?static=")
|
||||
|| s.ends_with(".m3u8")
|
||||
|| s.ends_with(".webm")
|
||||
|| s.ends_with(".ogg")
|
||||
|| s.ends_with(".mp4")
|
||||
|| s.ends_with(".mkv")
|
||||
|| s.ends_with(".mp3")
|
||||
}
|
||||
|
||||
fn parse_artist_title(s: &str) -> Option<(String, String)> {
|
||||
if let Some((artist, title)) = s.split_once(" - ") {
|
||||
let artist = artist.trim();
|
||||
let title = title.trim();
|
||||
if !artist.is_empty() && !title.is_empty() {
|
||||
return Some((artist.to_string(), title.to_string()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
enum InternalMpvEvent {
|
||||
StartFile,
|
||||
FileLoaded,
|
||||
PlaybackRestart,
|
||||
TimePos(f64),
|
||||
Duration(f64),
|
||||
Pause(bool),
|
||||
@@ -292,10 +326,14 @@ enum InternalMpvEvent {
|
||||
Mute(bool),
|
||||
MediaTitle(String),
|
||||
IcyTitle(String),
|
||||
PausedForCache(bool),
|
||||
CacheBufferingState(i64),
|
||||
CoreIdle(bool),
|
||||
Metadata {
|
||||
artist: Option<String>,
|
||||
genre: Option<String>,
|
||||
stream_title: Option<String>,
|
||||
title: Option<String>,
|
||||
},
|
||||
EndFile {
|
||||
reason: i32,
|
||||
@@ -369,6 +407,9 @@ impl PlayerController {
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 7, b"mute\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 8, b"metadata\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_NODE);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 9, b"eof-reached\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 10, b"paused-for-cache\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 11, b"cache-buffering-state\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_INT64);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 12, b"core-idle\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG);
|
||||
}
|
||||
|
||||
let status_arc = Arc::new(Mutex::new(initial_status));
|
||||
@@ -386,13 +427,31 @@ impl PlayerController {
|
||||
let mut st = status_consumer.lock().await;
|
||||
let mut notify = false;
|
||||
match event {
|
||||
InternalMpvEvent::StartFile | InternalMpvEvent::FileLoaded => {
|
||||
InternalMpvEvent::StartFile => {
|
||||
st.state = PlaybackState::Playing;
|
||||
st.is_loading = true;
|
||||
st.is_buffering = false;
|
||||
st.buffering_percent = None;
|
||||
st.error_message = None;
|
||||
notify = true;
|
||||
}
|
||||
InternalMpvEvent::FileLoaded => {
|
||||
st.state = PlaybackState::Playing;
|
||||
st.error_message = None;
|
||||
notify = true;
|
||||
}
|
||||
InternalMpvEvent::PlaybackRestart => {
|
||||
st.is_loading = false;
|
||||
st.is_buffering = false;
|
||||
st.buffering_percent = None;
|
||||
notify = true;
|
||||
}
|
||||
InternalMpvEvent::TimePos(pos) => {
|
||||
st.time_pos = pos;
|
||||
if st.is_loading && pos > 0.0 {
|
||||
st.is_loading = false;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::Duration(dur) => {
|
||||
st.duration = dur;
|
||||
@@ -416,46 +475,93 @@ impl PlayerController {
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::PausedForCache(paused) => {
|
||||
if st.is_buffering != paused {
|
||||
st.is_buffering = paused;
|
||||
if !paused {
|
||||
st.buffering_percent = None;
|
||||
}
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::CacheBufferingState(pct) => {
|
||||
if st.buffering_percent != Some(pct) {
|
||||
st.buffering_percent = Some(pct);
|
||||
if pct < 100 && !st.is_buffering {
|
||||
st.is_buffering = true;
|
||||
}
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::CoreIdle(idle) => {
|
||||
if !idle && st.is_loading {
|
||||
st.is_loading = false;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::MediaTitle(mt) => {
|
||||
if st.media_type == "stream" {
|
||||
if st.stream_title.is_empty() || st.stream_title != mt {
|
||||
st.stream_title = mt;
|
||||
if !is_url_or_query_string(&mt) {
|
||||
if st.media_type == "stream" {
|
||||
if (st.stream_title.is_empty() || st.stream_title != mt) && !st.stream_title.contains(" - ") {
|
||||
st.stream_title = mt;
|
||||
notify = true;
|
||||
}
|
||||
} else if st.title.is_empty() {
|
||||
st.title = mt;
|
||||
notify = true;
|
||||
}
|
||||
} else if st.title.is_empty() {
|
||||
st.title = mt;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::IcyTitle(icy) => {
|
||||
if st.stream_title != icy {
|
||||
st.stream_title = icy;
|
||||
if !is_url_or_query_string(&icy) && st.stream_title != icy {
|
||||
st.stream_title = icy.clone();
|
||||
st.media_type = "stream".to_string();
|
||||
if let Some((art, tit)) = parse_artist_title(&icy) {
|
||||
if st.artist.is_empty() {
|
||||
st.artist = art;
|
||||
}
|
||||
st.title = tit;
|
||||
}
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::Metadata { artist, genre, stream_title } => {
|
||||
InternalMpvEvent::Metadata { artist, genre, stream_title, title } => {
|
||||
if let Some(a) = artist {
|
||||
if st.artist != a {
|
||||
if !is_url_or_query_string(&a) && st.artist != a {
|
||||
st.artist = a;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
if let Some(g) = genre {
|
||||
if st.genre != g {
|
||||
if !is_url_or_query_string(&g) && st.genre != g {
|
||||
st.genre = g;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
if let Some(t) = title {
|
||||
if !is_url_or_query_string(&t) && st.title != t {
|
||||
st.title = t;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
if let Some(s) = stream_title {
|
||||
if st.stream_title != s {
|
||||
st.stream_title = s;
|
||||
if !is_url_or_query_string(&s) && st.stream_title != s {
|
||||
st.stream_title = s.clone();
|
||||
st.media_type = "stream".to_string();
|
||||
if let Some((art, tit)) = parse_artist_title(&s) {
|
||||
if st.artist.is_empty() {
|
||||
st.artist = art;
|
||||
}
|
||||
st.title = tit;
|
||||
}
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::EndFile { reason, error } => {
|
||||
st.is_loading = false;
|
||||
st.is_buffering = false;
|
||||
st.buffering_percent = None;
|
||||
if reason == libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_ERROR as i32 {
|
||||
let err_msg = if error != 0 {
|
||||
let msg = unsafe {
|
||||
@@ -482,6 +588,7 @@ impl PlayerController {
|
||||
st.media_type.clear();
|
||||
st.title.clear();
|
||||
st.artist.clear();
|
||||
st.playlist.clear();
|
||||
st.genre.clear();
|
||||
st.stream_title.clear();
|
||||
notify = true;
|
||||
@@ -580,6 +687,24 @@ impl PlayerController {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn prepare_loading(&self, title: &str, media_type: &str) {
|
||||
{
|
||||
let mut st = self.status.lock().await;
|
||||
st.state = PlaybackState::Playing;
|
||||
st.is_loading = true;
|
||||
st.is_buffering = false;
|
||||
st.buffering_percent = None;
|
||||
st.media_type = media_type.to_string();
|
||||
st.title = title.to_string();
|
||||
st.stream_title = if media_type == "stream" { title.to_string() } else { String::new() };
|
||||
st.time_pos = 0.0;
|
||||
st.duration = 0.0;
|
||||
st.error_message = None;
|
||||
}
|
||||
self.notify_status().await;
|
||||
}
|
||||
|
||||
pub async fn play(&self, url: &str, title: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let media_type = if url.contains("youtube.com") || url.contains("youtu.be") || (url.starts_with("http") && !url.contains("/Videos/") && !url.contains("/Audio/")) {
|
||||
"stream"
|
||||
@@ -603,6 +728,9 @@ impl PlayerController {
|
||||
{
|
||||
let mut st = self.status.lock().await;
|
||||
st.state = PlaybackState::Playing;
|
||||
st.is_loading = true;
|
||||
st.is_buffering = false;
|
||||
st.buffering_percent = None;
|
||||
st.media_type = media_type.to_string();
|
||||
st.title = title.to_string();
|
||||
st.artist = artist.unwrap_or_default().to_string();
|
||||
@@ -644,9 +772,13 @@ impl PlayerController {
|
||||
{
|
||||
let mut st = self.status.lock().await;
|
||||
st.state = PlaybackState::Stopped;
|
||||
st.is_loading = false;
|
||||
st.is_buffering = false;
|
||||
st.buffering_percent = None;
|
||||
st.media_type.clear();
|
||||
st.title.clear();
|
||||
st.artist.clear();
|
||||
st.playlist.clear();
|
||||
st.genre.clear();
|
||||
st.stream_title.clear();
|
||||
st.time_pos = 0.0;
|
||||
@@ -733,6 +865,111 @@ impl PlayerController {
|
||||
pub async fn set_video_visible(&self, _visible: bool) {
|
||||
// No-op with in-process libmpv OpenGL rendering; egui handles visibility directly.
|
||||
}
|
||||
|
||||
pub fn start_radio_chicken_updater(self: &Arc<Self>, api_url: String) {
|
||||
let player = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(4))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
let mut last_song_id = String::new();
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
|
||||
let is_chicken = {
|
||||
let st = player.status.lock().await;
|
||||
if st.state != PlaybackState::Playing {
|
||||
false
|
||||
} else {
|
||||
let title_lower = st.title.to_lowercase();
|
||||
let stream_lower = st.stream_title.to_lowercase();
|
||||
let artist_lower = st.artist.to_lowercase();
|
||||
let path = player.mpv.get_property_string("path").unwrap_or_default().to_lowercase();
|
||||
|
||||
title_lower.contains("chicken")
|
||||
|| stream_lower.contains("chicken")
|
||||
|| artist_lower.contains("chicken")
|
||||
|| path.contains("chicken")
|
||||
}
|
||||
};
|
||||
|
||||
if !is_chicken {
|
||||
last_song_id.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
match client.get(&api_url).send().await {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<RadioChickenApiData>().await {
|
||||
if let Some(song) = data.current {
|
||||
if !song.name.is_empty() && (song.id != last_song_id || last_song_id.is_empty()) {
|
||||
last_song_id = song.id.clone();
|
||||
let mut st = player.status.lock().await;
|
||||
if st.state == PlaybackState::Playing {
|
||||
let is_video = st.media_type == "video" || st.title.contains("TV");
|
||||
let playlist_name = if !song.playlist.is_empty() {
|
||||
song.playlist.clone()
|
||||
} else {
|
||||
song.category.clone()
|
||||
};
|
||||
st.artist = song.artist.clone();
|
||||
st.playlist = playlist_name.clone();
|
||||
st.genre = song.genre.clone();
|
||||
st.stream_title = format!("{} - {}", song.artist, song.name);
|
||||
st.title = if is_video {
|
||||
format!("Radio Chicken TV: {}", song.name)
|
||||
} else {
|
||||
format!("Radio Chicken: {}", song.name)
|
||||
};
|
||||
drop(st);
|
||||
player.notify_status().await;
|
||||
tracing::info!(
|
||||
"Radio Chicken metadata updated: artist='{}', title='{}', playlist='{}', genre='{}'",
|
||||
song.artist,
|
||||
song.name,
|
||||
playlist_name,
|
||||
song.genre
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to query Radio Chicken playlist API: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RadioChickenApiData {
|
||||
pub current: Option<RadioChickenCurrentSong>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub station: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||
pub struct RadioChickenCurrentSong {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub artist: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub category: String,
|
||||
#[serde(default)]
|
||||
pub playlist: String,
|
||||
#[serde(default)]
|
||||
pub genre: String,
|
||||
#[serde(default)]
|
||||
pub is_tts: bool,
|
||||
}
|
||||
|
||||
impl Drop for PlayerController {
|
||||
@@ -796,6 +1033,11 @@ fn run_mpv_event_loop(
|
||||
continue;
|
||||
}
|
||||
|
||||
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_PLAYBACK_RESTART {
|
||||
let _ = event_tx.send(InternalMpvEvent::PlaybackRestart);
|
||||
continue;
|
||||
}
|
||||
|
||||
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_END_FILE {
|
||||
let end_data = unsafe { (*ev).data as *const libmpv_sys::mpv_event_end_file };
|
||||
let (reason, error) = if !end_data.is_null() {
|
||||
@@ -881,6 +1123,7 @@ fn run_mpv_event_loop(
|
||||
let mut artist = None;
|
||||
let mut genre = None;
|
||||
let mut stream_title = None;
|
||||
let mut title = None;
|
||||
unsafe {
|
||||
let list = &*node.u.list;
|
||||
for i in 0..list.num {
|
||||
@@ -896,6 +1139,8 @@ fn run_mpv_event_loop(
|
||||
artist = Some(val.to_string());
|
||||
} else if key.eq_ignore_ascii_case("genre") {
|
||||
genre = Some(val.to_string());
|
||||
} else if key.eq_ignore_ascii_case("title") {
|
||||
title = Some(val.to_string());
|
||||
} else if key.eq_ignore_ascii_case("icy-title") || key.eq_ignore_ascii_case("StreamTitle") {
|
||||
stream_title = Some(val.to_string());
|
||||
}
|
||||
@@ -904,8 +1149,8 @@ fn run_mpv_event_loop(
|
||||
}
|
||||
}
|
||||
}
|
||||
if artist.is_some() || genre.is_some() || stream_title.is_some() {
|
||||
let _ = event_tx.send(InternalMpvEvent::Metadata { artist, genre, stream_title });
|
||||
if artist.is_some() || genre.is_some() || stream_title.is_some() || title.is_some() {
|
||||
let _ = event_tx.send(InternalMpvEvent::Metadata { artist, genre, stream_title, title });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -921,6 +1166,24 @@ fn run_mpv_event_loop(
|
||||
}
|
||||
}
|
||||
}
|
||||
"paused-for-cache" => {
|
||||
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_FLAG } && !unsafe { (*prop).data.is_null() } {
|
||||
let paused = unsafe { *((*prop).data as *const std::ffi::c_int) != 0 };
|
||||
let _ = event_tx.send(InternalMpvEvent::PausedForCache(paused));
|
||||
}
|
||||
}
|
||||
"cache-buffering-state" => {
|
||||
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_INT64 } && !unsafe { (*prop).data.is_null() } {
|
||||
let pct = unsafe { *((*prop).data as *const i64) };
|
||||
let _ = event_tx.send(InternalMpvEvent::CacheBufferingState(pct));
|
||||
}
|
||||
}
|
||||
"core-idle" => {
|
||||
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_FLAG } && !unsafe { (*prop).data.is_null() } {
|
||||
let idle = unsafe { *((*prop).data as *const std::ffi::c_int) != 0 };
|
||||
let _ = event_tx.send(InternalMpvEvent::CoreIdle(idle));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1102,4 +1365,55 @@ mod tests {
|
||||
];
|
||||
assert_eq!(select_subtitle_track(&tracks), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_url_or_query_string() {
|
||||
assert!(is_url_or_query_string("stream?static=true&api_key=123"));
|
||||
assert!(is_url_or_query_string("http://10.2.0.4:8096/stream"));
|
||||
assert!(is_url_or_query_string("https://example.com/live.m3u8"));
|
||||
assert!(is_url_or_query_string("audio.ogg"));
|
||||
assert!(is_url_or_query_string("video.webm"));
|
||||
assert!(!is_url_or_query_string("Radio Chicken (Audio)"));
|
||||
assert!(!is_url_or_query_string("Pink Floyd - Comfortably Numb"));
|
||||
assert!(!is_url_or_query_string("Bohemian Rhapsody"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_artist_title() {
|
||||
assert_eq!(
|
||||
parse_artist_title("Pink Floyd - Comfortably Numb"),
|
||||
Some(("Pink Floyd".to_string(), "Comfortably Numb".to_string()))
|
||||
);
|
||||
assert_eq!(parse_artist_title("Just A Title Without Dash"), None);
|
||||
assert_eq!(parse_artist_title(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_radio_chicken_api_deserialization() {
|
||||
let json_str = r#"{
|
||||
"station": "Radio Chicken",
|
||||
"current": {
|
||||
"id": "abc123",
|
||||
"artist": "Pixies",
|
||||
"name": "Where Is My Mind?",
|
||||
"category": "Alternative"
|
||||
}
|
||||
}"#;
|
||||
let data: RadioChickenApiData = serde_json::from_str(json_str).expect("Valid JSON");
|
||||
assert_eq!(data.station.as_deref(), Some("Radio Chicken"));
|
||||
let song = data.current.expect("Has current song");
|
||||
assert_eq!(song.id, "abc123");
|
||||
assert_eq!(song.artist, "Pixies");
|
||||
assert_eq!(song.name, "Where Is My Mind?");
|
||||
assert_eq!(song.category, "Alternative");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_player_status_loading_and_buffering_defaults() {
|
||||
let st = PlayerStatus::default();
|
||||
assert!(!st.is_loading);
|
||||
assert!(!st.is_buffering);
|
||||
assert_eq!(st.buffering_percent, None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user