diff --git a/jellyfin-kiosk-rs/src/mqtt.rs b/jellyfin-kiosk-rs/src/mqtt.rs index 9cff4b4..2e96486 100644 --- a/jellyfin-kiosk-rs/src/mqtt.rs +++ b/jellyfin-kiosk-rs/src/mqtt.rs @@ -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) { 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); diff --git a/jellyfin-kiosk-rs/src/player.rs b/jellyfin-kiosk-rs/src/player.rs index 5e12299..561deed 100644 --- a/jellyfin-kiosk-rs/src/player.rs +++ b/jellyfin-kiosk-rs/src/player.rs @@ -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, pub error_message: Option, } @@ -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, genre: Option, stream_title: Option, + title: Option, }, 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> { 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, 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::().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, + #[serde(default)] + #[allow(dead_code)] + pub station: Option, +} + +#[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); + } } +