feat: Add playlist and genre separation to MQTT playback state and discovery

This commit is contained in:
bart
2026-11-07 12:50:58 +01:00
parent a795c3d024
commit 4bb6fe007e
2 changed files with 366 additions and 18 deletions
+36 -2
View File
@@ -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
View File
@@ -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);
}
}