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 media_type: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub artist: String,
|
pub artist: String,
|
||||||
|
pub playlist: String,
|
||||||
pub genre: String,
|
pub genre: String,
|
||||||
pub stream_title: String,
|
pub stream_title: String,
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@ impl MqttPlaybackState {
|
|||||||
media_type: String::new(),
|
media_type: String::new(),
|
||||||
title: String::new(),
|
title: String::new(),
|
||||||
artist: String::new(),
|
artist: String::new(),
|
||||||
|
playlist: String::new(),
|
||||||
genre: String::new(),
|
genre: String::new(),
|
||||||
stream_title: String::new(),
|
stream_title: String::new(),
|
||||||
}
|
}
|
||||||
@@ -39,6 +41,7 @@ impl MqttPlaybackState {
|
|||||||
media_type: st.media_type.clone(),
|
media_type: st.media_type.clone(),
|
||||||
title: st.title.clone(),
|
title: st.title.clone(),
|
||||||
artist: st.artist.clone(),
|
artist: st.artist.clone(),
|
||||||
|
playlist: st.playlist.clone(),
|
||||||
genre: st.genre.clone(),
|
genre: st.genre.clone(),
|
||||||
stream_title: st.stream_title.clone(),
|
stream_title: st.stream_title.clone(),
|
||||||
}
|
}
|
||||||
@@ -58,8 +61,24 @@ impl MqttService {
|
|||||||
|
|
||||||
async fn run_mqtt_loop(config: AppConfig, player: Arc<PlayerController>) {
|
async fn run_mqtt_loop(config: AppConfig, player: Arc<PlayerController>) {
|
||||||
let mqtt_cfg = &config.mqtt;
|
let mqtt_cfg = &config.mqtt;
|
||||||
let device_id = &config.jellyfin.device_id;
|
let fallback_hostname = crate::config::get_system_hostname();
|
||||||
let device_name = &config.jellyfin.device_name;
|
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(|| {
|
let client_id = mqtt_cfg.client_id.clone().unwrap_or_else(|| {
|
||||||
format!("{}-{}", mqtt_cfg.topic_prefix, device_id)
|
format!("{}-{}", mqtt_cfg.topic_prefix, device_id)
|
||||||
@@ -196,6 +215,12 @@ pub async fn publish_homeassistant_discovery(
|
|||||||
value_template: "{{ value_json.artist }}",
|
value_template: "{{ value_json.artist }}",
|
||||||
icon: "mdi:account-music",
|
icon: "mdi:account-music",
|
||||||
},
|
},
|
||||||
|
SensorConfig {
|
||||||
|
object_id: "playlist",
|
||||||
|
name: "Playlist",
|
||||||
|
value_template: "{{ value_json.playlist }}",
|
||||||
|
icon: "mdi:playlist-music",
|
||||||
|
},
|
||||||
SensorConfig {
|
SensorConfig {
|
||||||
object_id: "genre",
|
object_id: "genre",
|
||||||
name: "Genre",
|
name: "Genre",
|
||||||
@@ -385,6 +410,7 @@ mod tests {
|
|||||||
volume: 100.0,
|
volume: 100.0,
|
||||||
is_muted: false,
|
is_muted: false,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
..PlayerStatus::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mqtt_state = MqttPlaybackState::from_status(&st);
|
let mqtt_state = MqttPlaybackState::from_status(&st);
|
||||||
@@ -392,6 +418,7 @@ mod tests {
|
|||||||
assert_eq!(mqtt_state.media_type, "");
|
assert_eq!(mqtt_state.media_type, "");
|
||||||
assert_eq!(mqtt_state.title, "");
|
assert_eq!(mqtt_state.title, "");
|
||||||
assert_eq!(mqtt_state.artist, "");
|
assert_eq!(mqtt_state.artist, "");
|
||||||
|
assert_eq!(mqtt_state.playlist, "");
|
||||||
assert_eq!(mqtt_state.genre, "");
|
assert_eq!(mqtt_state.genre, "");
|
||||||
assert_eq!(mqtt_state.stream_title, "");
|
assert_eq!(mqtt_state.stream_title, "");
|
||||||
}
|
}
|
||||||
@@ -403,6 +430,7 @@ mod tests {
|
|||||||
media_type: "music".to_string(),
|
media_type: "music".to_string(),
|
||||||
title: "Bohemian Rhapsody".to_string(),
|
title: "Bohemian Rhapsody".to_string(),
|
||||||
artist: "Queen".to_string(),
|
artist: "Queen".to_string(),
|
||||||
|
playlist: "Favorites".to_string(),
|
||||||
genre: "Rock".to_string(),
|
genre: "Rock".to_string(),
|
||||||
stream_title: "".to_string(),
|
stream_title: "".to_string(),
|
||||||
time_pos: 42.0,
|
time_pos: 42.0,
|
||||||
@@ -410,6 +438,7 @@ mod tests {
|
|||||||
volume: 85.0,
|
volume: 85.0,
|
||||||
is_muted: false,
|
is_muted: false,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
..PlayerStatus::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mqtt_state = MqttPlaybackState::from_status(&st);
|
let mqtt_state = MqttPlaybackState::from_status(&st);
|
||||||
@@ -417,6 +446,7 @@ mod tests {
|
|||||||
assert_eq!(mqtt_state.media_type, "music");
|
assert_eq!(mqtt_state.media_type, "music");
|
||||||
assert_eq!(mqtt_state.title, "Bohemian Rhapsody");
|
assert_eq!(mqtt_state.title, "Bohemian Rhapsody");
|
||||||
assert_eq!(mqtt_state.artist, "Queen");
|
assert_eq!(mqtt_state.artist, "Queen");
|
||||||
|
assert_eq!(mqtt_state.playlist, "Favorites");
|
||||||
assert_eq!(mqtt_state.genre, "Rock");
|
assert_eq!(mqtt_state.genre, "Rock");
|
||||||
assert_eq!(mqtt_state.stream_title, "");
|
assert_eq!(mqtt_state.stream_title, "");
|
||||||
}
|
}
|
||||||
@@ -428,6 +458,7 @@ mod tests {
|
|||||||
media_type: "stream".to_string(),
|
media_type: "stream".to_string(),
|
||||||
title: "Radio Veronica".to_string(),
|
title: "Radio Veronica".to_string(),
|
||||||
artist: "Pink Floyd".to_string(),
|
artist: "Pink Floyd".to_string(),
|
||||||
|
playlist: "Radio".to_string(),
|
||||||
genre: "Classic Rock".to_string(),
|
genre: "Classic Rock".to_string(),
|
||||||
stream_title: "Pink Floyd - Comfortably Numb".to_string(),
|
stream_title: "Pink Floyd - Comfortably Numb".to_string(),
|
||||||
time_pos: 120.0,
|
time_pos: 120.0,
|
||||||
@@ -435,6 +466,7 @@ mod tests {
|
|||||||
volume: 90.0,
|
volume: 90.0,
|
||||||
is_muted: false,
|
is_muted: false,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
..PlayerStatus::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mqtt_state = MqttPlaybackState::from_status(&st);
|
let mqtt_state = MqttPlaybackState::from_status(&st);
|
||||||
@@ -442,6 +474,7 @@ mod tests {
|
|||||||
assert_eq!(mqtt_state.media_type, "stream");
|
assert_eq!(mqtt_state.media_type, "stream");
|
||||||
assert_eq!(mqtt_state.title, "Radio Veronica");
|
assert_eq!(mqtt_state.title, "Radio Veronica");
|
||||||
assert_eq!(mqtt_state.artist, "Pink Floyd");
|
assert_eq!(mqtt_state.artist, "Pink Floyd");
|
||||||
|
assert_eq!(mqtt_state.playlist, "Radio");
|
||||||
assert_eq!(mqtt_state.genre, "Classic Rock");
|
assert_eq!(mqtt_state.genre, "Classic Rock");
|
||||||
assert_eq!(mqtt_state.stream_title, "Pink Floyd - Comfortably Numb");
|
assert_eq!(mqtt_state.stream_title, "Pink Floyd - Comfortably Numb");
|
||||||
}
|
}
|
||||||
@@ -460,6 +493,7 @@ mod tests {
|
|||||||
volume: 100.0,
|
volume: 100.0,
|
||||||
is_muted: false,
|
is_muted: false,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
..PlayerStatus::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mqtt_state = MqttPlaybackState::from_status(&st);
|
let mqtt_state = MqttPlaybackState::from_status(&st);
|
||||||
|
|||||||
+325
-11
@@ -19,12 +19,16 @@ pub struct PlayerStatus {
|
|||||||
pub media_type: String,
|
pub media_type: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub artist: String,
|
pub artist: String,
|
||||||
|
pub playlist: String,
|
||||||
pub genre: String,
|
pub genre: String,
|
||||||
pub stream_title: String,
|
pub stream_title: String,
|
||||||
pub time_pos: f64,
|
pub time_pos: f64,
|
||||||
pub duration: f64,
|
pub duration: f64,
|
||||||
pub volume: f64,
|
pub volume: f64,
|
||||||
pub is_muted: bool,
|
pub is_muted: bool,
|
||||||
|
pub is_loading: bool,
|
||||||
|
pub is_buffering: bool,
|
||||||
|
pub buffering_percent: Option<i64>,
|
||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,12 +39,16 @@ impl Default for PlayerStatus {
|
|||||||
media_type: String::new(),
|
media_type: String::new(),
|
||||||
title: String::new(),
|
title: String::new(),
|
||||||
artist: String::new(),
|
artist: String::new(),
|
||||||
|
playlist: String::new(),
|
||||||
genre: String::new(),
|
genre: String::new(),
|
||||||
stream_title: String::new(),
|
stream_title: String::new(),
|
||||||
time_pos: 0.0,
|
time_pos: 0.0,
|
||||||
duration: 0.0,
|
duration: 0.0,
|
||||||
volume: 100.0,
|
volume: 100.0,
|
||||||
is_muted: false,
|
is_muted: false,
|
||||||
|
is_loading: false,
|
||||||
|
is_buffering: false,
|
||||||
|
buffering_percent: None,
|
||||||
error_message: 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 {
|
enum InternalMpvEvent {
|
||||||
StartFile,
|
StartFile,
|
||||||
FileLoaded,
|
FileLoaded,
|
||||||
|
PlaybackRestart,
|
||||||
TimePos(f64),
|
TimePos(f64),
|
||||||
Duration(f64),
|
Duration(f64),
|
||||||
Pause(bool),
|
Pause(bool),
|
||||||
@@ -292,10 +326,14 @@ enum InternalMpvEvent {
|
|||||||
Mute(bool),
|
Mute(bool),
|
||||||
MediaTitle(String),
|
MediaTitle(String),
|
||||||
IcyTitle(String),
|
IcyTitle(String),
|
||||||
|
PausedForCache(bool),
|
||||||
|
CacheBufferingState(i64),
|
||||||
|
CoreIdle(bool),
|
||||||
Metadata {
|
Metadata {
|
||||||
artist: Option<String>,
|
artist: Option<String>,
|
||||||
genre: Option<String>,
|
genre: Option<String>,
|
||||||
stream_title: Option<String>,
|
stream_title: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
},
|
},
|
||||||
EndFile {
|
EndFile {
|
||||||
reason: i32,
|
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, 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, 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, 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));
|
let status_arc = Arc::new(Mutex::new(initial_status));
|
||||||
@@ -386,13 +427,31 @@ impl PlayerController {
|
|||||||
let mut st = status_consumer.lock().await;
|
let mut st = status_consumer.lock().await;
|
||||||
let mut notify = false;
|
let mut notify = false;
|
||||||
match event {
|
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.state = PlaybackState::Playing;
|
||||||
st.error_message = None;
|
st.error_message = None;
|
||||||
notify = true;
|
notify = true;
|
||||||
}
|
}
|
||||||
|
InternalMpvEvent::PlaybackRestart => {
|
||||||
|
st.is_loading = false;
|
||||||
|
st.is_buffering = false;
|
||||||
|
st.buffering_percent = None;
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
InternalMpvEvent::TimePos(pos) => {
|
InternalMpvEvent::TimePos(pos) => {
|
||||||
st.time_pos = pos;
|
st.time_pos = pos;
|
||||||
|
if st.is_loading && pos > 0.0 {
|
||||||
|
st.is_loading = false;
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
InternalMpvEvent::Duration(dur) => {
|
InternalMpvEvent::Duration(dur) => {
|
||||||
st.duration = dur;
|
st.duration = dur;
|
||||||
@@ -416,9 +475,34 @@ impl PlayerController {
|
|||||||
notify = true;
|
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) => {
|
InternalMpvEvent::MediaTitle(mt) => {
|
||||||
|
if !is_url_or_query_string(&mt) {
|
||||||
if st.media_type == "stream" {
|
if st.media_type == "stream" {
|
||||||
if st.stream_title.is_empty() || st.stream_title != mt {
|
if (st.stream_title.is_empty() || st.stream_title != mt) && !st.stream_title.contains(" - ") {
|
||||||
st.stream_title = mt;
|
st.stream_title = mt;
|
||||||
notify = true;
|
notify = true;
|
||||||
}
|
}
|
||||||
@@ -427,35 +511,57 @@ impl PlayerController {
|
|||||||
notify = true;
|
notify = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
InternalMpvEvent::IcyTitle(icy) => {
|
InternalMpvEvent::IcyTitle(icy) => {
|
||||||
if st.stream_title != icy {
|
if !is_url_or_query_string(&icy) && st.stream_title != icy {
|
||||||
st.stream_title = icy;
|
st.stream_title = icy.clone();
|
||||||
st.media_type = "stream".to_string();
|
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;
|
notify = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InternalMpvEvent::Metadata { artist, genre, stream_title } => {
|
InternalMpvEvent::Metadata { artist, genre, stream_title, title } => {
|
||||||
if let Some(a) = artist {
|
if let Some(a) = artist {
|
||||||
if st.artist != a {
|
if !is_url_or_query_string(&a) && st.artist != a {
|
||||||
st.artist = a;
|
st.artist = a;
|
||||||
notify = true;
|
notify = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(g) = genre {
|
if let Some(g) = genre {
|
||||||
if st.genre != g {
|
if !is_url_or_query_string(&g) && st.genre != g {
|
||||||
st.genre = g;
|
st.genre = g;
|
||||||
notify = true;
|
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 let Some(s) = stream_title {
|
||||||
if st.stream_title != s {
|
if !is_url_or_query_string(&s) && st.stream_title != s {
|
||||||
st.stream_title = s;
|
st.stream_title = s.clone();
|
||||||
st.media_type = "stream".to_string();
|
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;
|
notify = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InternalMpvEvent::EndFile { reason, error } => {
|
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 {
|
if reason == libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_ERROR as i32 {
|
||||||
let err_msg = if error != 0 {
|
let err_msg = if error != 0 {
|
||||||
let msg = unsafe {
|
let msg = unsafe {
|
||||||
@@ -482,6 +588,7 @@ impl PlayerController {
|
|||||||
st.media_type.clear();
|
st.media_type.clear();
|
||||||
st.title.clear();
|
st.title.clear();
|
||||||
st.artist.clear();
|
st.artist.clear();
|
||||||
|
st.playlist.clear();
|
||||||
st.genre.clear();
|
st.genre.clear();
|
||||||
st.stream_title.clear();
|
st.stream_title.clear();
|
||||||
notify = true;
|
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>> {
|
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/")) {
|
let media_type = if url.contains("youtube.com") || url.contains("youtu.be") || (url.starts_with("http") && !url.contains("/Videos/") && !url.contains("/Audio/")) {
|
||||||
"stream"
|
"stream"
|
||||||
@@ -603,6 +728,9 @@ impl PlayerController {
|
|||||||
{
|
{
|
||||||
let mut st = self.status.lock().await;
|
let mut st = self.status.lock().await;
|
||||||
st.state = PlaybackState::Playing;
|
st.state = PlaybackState::Playing;
|
||||||
|
st.is_loading = true;
|
||||||
|
st.is_buffering = false;
|
||||||
|
st.buffering_percent = None;
|
||||||
st.media_type = media_type.to_string();
|
st.media_type = media_type.to_string();
|
||||||
st.title = title.to_string();
|
st.title = title.to_string();
|
||||||
st.artist = artist.unwrap_or_default().to_string();
|
st.artist = artist.unwrap_or_default().to_string();
|
||||||
@@ -644,9 +772,13 @@ impl PlayerController {
|
|||||||
{
|
{
|
||||||
let mut st = self.status.lock().await;
|
let mut st = self.status.lock().await;
|
||||||
st.state = PlaybackState::Stopped;
|
st.state = PlaybackState::Stopped;
|
||||||
|
st.is_loading = false;
|
||||||
|
st.is_buffering = false;
|
||||||
|
st.buffering_percent = None;
|
||||||
st.media_type.clear();
|
st.media_type.clear();
|
||||||
st.title.clear();
|
st.title.clear();
|
||||||
st.artist.clear();
|
st.artist.clear();
|
||||||
|
st.playlist.clear();
|
||||||
st.genre.clear();
|
st.genre.clear();
|
||||||
st.stream_title.clear();
|
st.stream_title.clear();
|
||||||
st.time_pos = 0.0;
|
st.time_pos = 0.0;
|
||||||
@@ -733,6 +865,111 @@ impl PlayerController {
|
|||||||
pub async fn set_video_visible(&self, _visible: bool) {
|
pub async fn set_video_visible(&self, _visible: bool) {
|
||||||
// No-op with in-process libmpv OpenGL rendering; egui handles visibility directly.
|
// 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 {
|
impl Drop for PlayerController {
|
||||||
@@ -796,6 +1033,11 @@ fn run_mpv_event_loop(
|
|||||||
continue;
|
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 {
|
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 end_data = unsafe { (*ev).data as *const libmpv_sys::mpv_event_end_file };
|
||||||
let (reason, error) = if !end_data.is_null() {
|
let (reason, error) = if !end_data.is_null() {
|
||||||
@@ -881,6 +1123,7 @@ fn run_mpv_event_loop(
|
|||||||
let mut artist = None;
|
let mut artist = None;
|
||||||
let mut genre = None;
|
let mut genre = None;
|
||||||
let mut stream_title = None;
|
let mut stream_title = None;
|
||||||
|
let mut title = None;
|
||||||
unsafe {
|
unsafe {
|
||||||
let list = &*node.u.list;
|
let list = &*node.u.list;
|
||||||
for i in 0..list.num {
|
for i in 0..list.num {
|
||||||
@@ -896,6 +1139,8 @@ fn run_mpv_event_loop(
|
|||||||
artist = Some(val.to_string());
|
artist = Some(val.to_string());
|
||||||
} else if key.eq_ignore_ascii_case("genre") {
|
} else if key.eq_ignore_ascii_case("genre") {
|
||||||
genre = Some(val.to_string());
|
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") {
|
} else if key.eq_ignore_ascii_case("icy-title") || key.eq_ignore_ascii_case("StreamTitle") {
|
||||||
stream_title = Some(val.to_string());
|
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() {
|
if artist.is_some() || genre.is_some() || stream_title.is_some() || title.is_some() {
|
||||||
let _ = event_tx.send(InternalMpvEvent::Metadata { artist, genre, stream_title });
|
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);
|
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