Compare commits

...
4 Commits
6301 changed files with 19963 additions and 177 deletions
+1 -1
View File
@@ -7,4 +7,4 @@ dist/
build/
*.egg-info/
.pytest_cache/
jellyfin-kiosk-rs/target
jellyfin-kiosk-rs/target*.log
+1 -1
View File
@@ -1460,7 +1460,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jellyfin-kiosk-rs"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"axum",
"eframe",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellyfin-kiosk-rs"
version = "0.1.4"
version = "0.1.5"
edition = "2021"
[dependencies]
Binary file not shown.
-12
View File
@@ -1,12 +0,0 @@
2026-11-05T21:13:59.466559Z  INFO jellyfin_kiosk_rs: Starting Jellyfin HTPC Kiosk (Rust)...
2026-11-05T21:13:59.467206Z  INFO jellyfin_kiosk_rs::config: Loaded config from ../config.yaml
2026-11-05T21:13:59.474938Z  INFO jellyfin_kiosk_rs::player: libmpv initialized successfully (vo=libmpv, hwdec=auto)
2026-11-05T21:13:59.549439Z  INFO jellyfin_kiosk_rs::yatse: Yatse Kodi TCP JSON-RPC socket server listening on 0.0.0.0:9090
2026-11-05T21:13:59.549444Z  INFO jellyfin_kiosk_rs: Yatse Kodi JSON-RPC server listening on http://0.0.0.0:8080
2026-11-05T21:13:59.549923Z  INFO jellyfin_kiosk_rs::eventserver: Kodi EventServer listening on UDP 0.0.0.0:9777
2026-11-05T21:13:59.551471Z  INFO jellyfin_kiosk_rs: Published Zeroconf services for Yatse discovery: 'Kodi (bart-laptop)'
2026-11-05T21:13:59.691500Z  INFO winit::platform_impl::linux::x11::window: Guessed window scale factor: 1
2026-11-05T21:13:59.729454Z  INFO jellyfin_kiosk_rs::youtube: Loaded cached YouTube token from youtube_token.json
Cannot load libcuda.so.1
2026-11-05T21:13:59.854273Z  INFO jellyfin_kiosk_rs::player: libmpv OpenGL render context created successfully!
2026-11-05T21:13:59.916581Z  INFO jellyfin_kiosk_rs::jellyfin: Authenticated successfully with Jellyfin as bart
+13 -13
View File
@@ -431,8 +431,8 @@ impl KioskApp {
}
if item.item_type == "YouTubeVideo" || item.id.starts_with("http") {
self.view_mode = ViewMode::Player;
self.playback_osd_open = true;
self.playback_osd_idx = 2;
self.playback_osd_open = false;
self.playback_osd_until = None;
let player = self.player.clone();
let url = item.id.clone();
let name = item.name.clone();
@@ -1239,8 +1239,8 @@ impl KioskApp {
// Play first track or all tracks
if let Some(first_track) = self.folder_items.first() {
self.view_mode = ViewMode::Player;
self.playback_osd_open = true;
self.playback_osd_idx = 2;
self.playback_osd_open = false;
self.playback_osd_until = None;
let client = self.client.clone();
let player = self.player.clone();
let item = first_track.clone();
@@ -1254,8 +1254,8 @@ impl KioskApp {
let track_idx = self.selected_grid_idx - 1;
if let Some(track) = self.folder_items.get(track_idx) {
self.view_mode = ViewMode::Player;
self.playback_osd_open = true;
self.playback_osd_idx = 2;
self.playback_osd_open = false;
self.playback_osd_until = None;
let client = self.client.clone();
let player = self.player.clone();
let item = track.clone();
@@ -1659,11 +1659,11 @@ impl eframe::App for KioskApp {
let is_new_item = !current_title.is_empty() && current_title != self.last_playing_title;
if is_playing && (!self.was_playing || is_new_item) {
self.view_mode = ViewMode::Player;
self.playback_osd_open = true;
self.playback_osd_idx = 2; // Pause/Play
self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4));
self.playback_osd_open = false; // Als een item begint met spelen moet het afspeelmenu niet worden getoond
self.playback_osd_until = None;
self.last_playing_title = current_title;
} else if !is_playing && self.was_playing {
} else if !is_playing && (self.was_playing || self.view_mode == ViewMode::Player) {
// Als een item klaar is met afspelen moet hij terug naar het menu
self.view_mode = ViewMode::Browser;
self.playback_osd_open = false;
self.playback_osd_until = None;
@@ -1681,7 +1681,7 @@ impl eframe::App for KioskApp {
}
}
if is_playing || self.volume_hud_until.is_some() || self.playback_osd_until.is_some() || self.osd_notice.is_some() || self.folder_loading {
if is_playing || self.view_mode == ViewMode::Player || self.volume_hud_until.is_some() || self.playback_osd_until.is_some() || self.osd_notice.is_some() || self.folder_loading {
ctx.request_repaint_after(Duration::from_millis(100));
}
@@ -3153,8 +3153,8 @@ impl eframe::App for KioskApp {
if let Some(t_idx) = clicked_track_idx {
self.selected_grid_idx = t_idx + 1;
self.view_mode = ViewMode::Player;
self.playback_osd_open = true;
self.playback_osd_idx = 2;
self.playback_osd_open = false;
self.playback_osd_until = None;
if let Some(track) = self.folder_items.get(t_idx) {
let client = self.client.clone();
let player = self.player.clone();
+186 -59
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,8 +41,13 @@ 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(),
stream_title: if st.artist == "Radio Chicken DJ" || st.stream_title.contains("Radio Chicken DJ") {
String::new()
} else {
st.stream_title.clone()
},
}
}
}
@@ -58,8 +65,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)
@@ -90,63 +113,79 @@ async fn run_mqtt_loop(config: AppConfig, player: Arc<PlayerController>) {
device_name
);
let mut status_rx = player.status_watch_rx();
let mut last_published: Option<MqttPlaybackState> = None;
let is_connected = Arc::new(std::sync::atomic::AtomicBool::new(false));
loop {
let (client, mut eventloop) = AsyncClient::new(mqtt_opts.clone(), 30);
let mut is_connected = false;
let (client, mut eventloop) = AsyncClient::new(mqtt_opts.clone(), 100);
is_connected.store(false, std::sync::atomic::Ordering::SeqCst);
loop {
tokio::select! {
event_res = eventloop.poll() => {
match event_res {
Ok(rumqttc::Event::Incoming(Packet::ConnAck(_))) => {
is_connected = true;
tracing::info!("MQTT broker connected ({}:{})", mqtt_cfg.host, mqtt_cfg.port);
let (cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel::<()>();
let client_clone = client.clone();
let mqtt_cfg_clone = mqtt_cfg.clone();
let device_id_string = device_id.to_string();
let is_connected_clone = is_connected.clone();
let mut status_rx_clone = player.status_watch_rx();
// 1. Announce online availability
let _ = client.publish(&avail_topic, QoS::AtLeastOnce, true, "online").await;
// 2. Subscribe to control commands
let _ = client.subscribe(&command_topic, QoS::AtLeastOnce).await;
// 3. Publish Home Assistant Discovery configs
publish_homeassistant_discovery(&client, mqtt_cfg, device_id, device_name).await;
// 4. Publish current playback state
let current_st = {
let guard = player.status.lock().await;
MqttPlaybackState::from_status(&guard)
};
publish_playback_state(&client, mqtt_cfg, device_id, &current_st).await;
last_published = Some(current_st);
let watcher_handle = tokio::spawn(async move {
let mut last_published: Option<MqttPlaybackState> = None;
loop {
tokio::select! {
_ = &mut cancel_rx => break,
res = status_rx_clone.changed() => {
if res.is_err() {
break;
}
Ok(rumqttc::Event::Incoming(Packet::Publish(p))) => {
if p.topic == command_topic {
if let Ok(cmd_str) = std::str::from_utf8(&p.payload) {
handle_remote_command(&player, cmd_str.trim()).await;
}
if is_connected_clone.load(std::sync::atomic::Ordering::SeqCst) {
let st = status_rx_clone.borrow().clone();
let new_state = MqttPlaybackState::from_status(&st);
if last_published.as_ref() != Some(&new_state) {
publish_playback_state(&client_clone, &mqtt_cfg_clone, &device_id_string, &new_state).await;
last_published = Some(new_state);
}
}
Ok(_) => {}
Err(e) => {
tracing::warn!("MQTT connection lost or error: {}. Reconnecting...", e);
tokio::time::sleep(Duration::from_secs(3)).await;
break; // break inner loop to re-create client/eventloop
}
}
}
changed_res = status_rx.changed() => {
if changed_res.is_ok() && is_connected {
let st = status_rx.borrow().clone();
let new_state = MqttPlaybackState::from_status(&st);
if last_published.as_ref() != Some(&new_state) {
publish_playback_state(&client, mqtt_cfg, device_id, &new_state).await;
last_published = Some(new_state);
}
});
loop {
match eventloop.poll().await {
Ok(rumqttc::Event::Incoming(Packet::ConnAck(_))) => {
is_connected.store(true, std::sync::atomic::Ordering::SeqCst);
tracing::info!("MQTT broker connected ({}:{})", mqtt_cfg.host, mqtt_cfg.port);
// 1. Announce online availability
let _ = client.publish(&avail_topic, QoS::AtLeastOnce, true, "online").await;
// 2. Subscribe to control commands
let _ = client.subscribe(&command_topic, QoS::AtLeastOnce).await;
// 3. Publish Home Assistant Discovery configs
publish_homeassistant_discovery(&client, mqtt_cfg, device_id, device_name).await;
// 4. Publish current playback state
let current_st = {
let guard = player.status.lock().await;
MqttPlaybackState::from_status(&guard)
};
publish_playback_state(&client, mqtt_cfg, device_id, &current_st).await;
}
Ok(rumqttc::Event::Incoming(Packet::Publish(p))) => {
if p.topic == command_topic {
if let Ok(cmd_str) = std::str::from_utf8(&p.payload) {
handle_remote_command(&player, cmd_str.trim()).await;
}
}
}
Ok(_) => {}
Err(e) => {
is_connected.store(false, std::sync::atomic::Ordering::SeqCst);
tracing::warn!("MQTT connection lost or error: {}. Reconnecting...", e);
let _ = cancel_tx.send(());
watcher_handle.abort();
tokio::time::sleep(Duration::from_secs(3)).await;
break; // break inner loop to re-create client/eventloop
}
}
}
}
@@ -160,6 +199,7 @@ pub async fn publish_homeassistant_discovery(
) {
let state_topic = format!("{}/{}/state", mqtt_cfg.topic_prefix, device_id);
let avail_topic = format!("{}/{}/availability", mqtt_cfg.topic_prefix, device_id);
let command_topic = format!("{}/{}/command", mqtt_cfg.topic_prefix, device_id);
let device_obj = json!({
"identifiers": [device_id],
@@ -169,12 +209,6 @@ pub async fn publish_homeassistant_discovery(
"sw_version": env!("CARGO_PKG_VERSION")
});
let availability_obj = json!([{
"topic": avail_topic,
"payload_available": "online",
"payload_not_available": "offline"
}]);
struct SensorConfig<'a> {
object_id: &'a str,
name: &'a str,
@@ -201,6 +235,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",
@@ -227,20 +267,76 @@ pub async fn publish_homeassistant_discovery(
mqtt_cfg.discovery_prefix, device_id, s.object_id
);
let payload = json!({
"name": format!("{} {}", device_name, s.name),
"name": s.name,
"has_entity_name": true,
"unique_id": format!("{}_{}", device_id, s.object_id),
"state_topic": state_topic,
"value_template": s.value_template,
"json_attributes_topic": state_topic,
"icon": s.icon,
"device": device_obj,
"availability": availability_obj
"availability_topic": avail_topic,
"payload_available": "online",
"payload_not_available": "offline"
});
if let Err(e) = client.publish(&disc_topic, QoS::AtLeastOnce, true, payload.to_string()).await {
tracing::warn!("Failed to publish Home Assistant discovery for {}: {}", s.object_id, e);
tracing::warn!("Failed to publish Home Assistant discovery for sensor {}: {}", s.object_id, e);
} else {
tracing::debug!("Published HA discovery: {}", disc_topic);
tracing::info!("Published HA sensor discovery: {}", disc_topic);
}
}
struct ButtonConfig<'a> {
object_id: &'a str,
name: &'a str,
payload_press: &'a str,
icon: &'a str,
}
let buttons = [
ButtonConfig {
object_id: "play_pause",
name: "Play / Pause",
payload_press: "play_pause",
icon: "mdi:play-pause",
},
ButtonConfig {
object_id: "stop",
name: "Stop",
payload_press: "stop",
icon: "mdi:stop",
},
ButtonConfig {
object_id: "mute",
name: "Mute",
payload_press: "toggle_mute",
icon: "mdi:volume-mute",
},
];
for b in &buttons {
let disc_topic = format!(
"{}/button/{}/{}/config",
mqtt_cfg.discovery_prefix, device_id, b.object_id
);
let payload = json!({
"name": b.name,
"has_entity_name": true,
"unique_id": format!("{}_{}", device_id, b.object_id),
"command_topic": command_topic,
"payload_press": b.payload_press,
"icon": b.icon,
"device": device_obj,
"availability_topic": avail_topic,
"payload_available": "online",
"payload_not_available": "offline"
});
if let Err(e) = client.publish(&disc_topic, QoS::AtLeastOnce, true, payload.to_string()).await {
tracing::warn!("Failed to publish Home Assistant discovery for button {}: {}", b.object_id, e);
} else {
tracing::info!("Published HA button discovery: {}", disc_topic);
}
}
@@ -258,6 +354,7 @@ pub async fn publish_playback_state(
"media_type": state.media_type,
"title": state.title,
"artist": state.artist,
"playlist": state.playlist,
"genre": state.genre,
"stream_title": state.stream_title,
});
@@ -269,15 +366,17 @@ pub async fn publish_playback_state(
let _ = client.publish(format!("{}/{}/media_type", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.media_type.as_str()).await;
let _ = client.publish(format!("{}/{}/title", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.title.as_str()).await;
let _ = client.publish(format!("{}/{}/artist", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.artist.as_str()).await;
let _ = client.publish(format!("{}/{}/playlist", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.playlist.as_str()).await;
let _ = client.publish(format!("{}/{}/genre", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.genre.as_str()).await;
let _ = client.publish(format!("{}/{}/stream_title", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.stream_title.as_str()).await;
let _ = client.publish(format!("{}/{}/playback_state", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.state.as_str()).await;
tracing::info!(
"MQTT state published: media_type='{}', title='{}', artist='{}', genre='{}', stream_title='{}', state='{}'",
"MQTT state published: media_type='{}', title='{}', artist='{}', playlist='{}', genre='{}', stream_title='{}', state='{}'",
state.media_type,
state.title,
state.artist,
state.playlist,
state.genre,
state.stream_title,
state.state
@@ -334,6 +433,7 @@ mod tests {
volume: 100.0,
is_muted: false,
error_message: None,
..PlayerStatus::default()
};
let mqtt_state = MqttPlaybackState::from_status(&st);
@@ -341,6 +441,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, "");
}
@@ -352,6 +453,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,
@@ -359,6 +461,7 @@ mod tests {
volume: 85.0,
is_muted: false,
error_message: None,
..PlayerStatus::default()
};
let mqtt_state = MqttPlaybackState::from_status(&st);
@@ -366,6 +469,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, "");
}
@@ -377,6 +481,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,
@@ -384,6 +489,7 @@ mod tests {
volume: 90.0,
is_muted: false,
error_message: None,
..PlayerStatus::default()
};
let mqtt_state = MqttPlaybackState::from_status(&st);
@@ -391,6 +497,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");
}
@@ -409,6 +516,7 @@ mod tests {
volume: 100.0,
is_muted: false,
error_message: None,
..PlayerStatus::default()
};
let mqtt_state = MqttPlaybackState::from_status(&st);
@@ -420,6 +528,25 @@ mod tests {
assert_eq!(mqtt_state.stream_title, "");
}
#[test]
fn test_dj_announcement_stream_title_empty() {
let st = PlayerStatus {
state: PlaybackState::Playing,
media_type: "video".to_string(),
title: "Radio Chicken TV: Up next: Iron Maiden - The Trooper".to_string(),
artist: "Radio Chicken DJ".to_string(),
playlist: "HEUL_HARD".to_string(),
genre: "DJ".to_string(),
stream_title: "Radio Chicken DJ - Up next: Iron Maiden - The Trooper".to_string(),
..PlayerStatus::default()
};
let mqtt_state = MqttPlaybackState::from_status(&st);
assert_eq!(mqtt_state.state, "playing");
assert_eq!(mqtt_state.artist, "Radio Chicken DJ");
assert_eq!(mqtt_state.stream_title, "");
}
#[test]
fn test_yaml_config_deserialization() {
let yaml_str = r#"
+703 -20
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,17 +39,51 @@ 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,
}
}
}
#[derive(Debug, Clone)]
pub struct TrackInfo {
pub id: String,
pub track_type: String, // "audio", "sub", "video"
pub lang: String,
pub title: String,
pub is_default: bool,
pub is_forced: bool,
#[allow(dead_code)]
pub is_selected: bool,
}
pub fn is_english_lang(lang: &str, title: &str) -> bool {
let l = lang.trim().to_lowercase();
if l == "en" || l == "eng" || l.starts_with("en-") || l.starts_with("en_") {
return true;
}
let t = title.to_lowercase();
t.contains("english") || t.contains("engels")
}
pub fn is_dutch_lang(lang: &str, title: &str) -> bool {
let l = lang.trim().to_lowercase();
if l == "nl" || l == "nld" || l == "dut" || l.starts_with("nl-") || l.starts_with("nl_") {
return true;
}
let t = title.to_lowercase();
t.contains("dutch") || t.contains("nederlands")
}
#[derive(Clone, Copy)]
pub struct MpvHandle(pub *mut libmpv_sys::mpv_handle);
unsafe impl Send for MpvHandle {}
@@ -74,6 +112,123 @@ impl MpvHandle {
}
}
}
pub fn get_property_string(&self, name: &str) -> Option<String> {
if self.0.is_null() {
return None;
}
let c_name = CString::new(name).ok()?;
unsafe {
let ptr = libmpv_sys::mpv_get_property_string(self.0, c_name.as_ptr());
if ptr.is_null() {
None
} else {
let s = CStr::from_ptr(ptr).to_string_lossy().into_owned();
libmpv_sys::mpv_free(ptr as *mut _);
Some(s)
}
}
}
pub fn get_tracks(&self) -> Vec<TrackInfo> {
let count_str = self.get_property_string("track-list/count").unwrap_or_default();
let count: usize = count_str.parse().unwrap_or(0);
let mut tracks = Vec::with_capacity(count);
for i in 0..count {
let track_type = self.get_property_string(&format!("track-list/{}/type", i)).unwrap_or_default();
let id = self.get_property_string(&format!("track-list/{}/id", i)).unwrap_or_default();
let lang = self.get_property_string(&format!("track-list/{}/lang", i)).unwrap_or_default().to_lowercase();
let title = self.get_property_string(&format!("track-list/{}/title", i)).unwrap_or_default();
let is_default = self.get_property_string(&format!("track-list/{}/default", i)).as_deref() == Some("yes");
let is_forced = self.get_property_string(&format!("track-list/{}/forced", i)).as_deref() == Some("yes");
let is_selected = self.get_property_string(&format!("track-list/{}/selected", i)).as_deref() == Some("yes");
tracks.push(TrackInfo {
id,
track_type,
lang,
title,
is_default,
is_forced,
is_selected,
});
}
tracks
}
pub fn auto_select_tracks(&self) {
if self.0.is_null() {
return;
}
let tracks = self.get_tracks();
if tracks.is_empty() {
return;
}
if let Some(aid) = select_audio_track(&tracks) {
tracing::info!("Audio stream selected: aid={}", aid);
self.cmd(&["set", "aid", aid]);
}
if let Some(sid) = select_subtitle_track(&tracks) {
tracing::info!("Subtitle stream selected: sid={}", sid);
self.cmd(&["set", "sid", sid]);
self.cmd(&["set", "sub-visibility", "yes"]);
} else {
self.cmd(&["set", "sid", "no"]);
}
}
}
pub fn select_audio_track<'a>(tracks: &'a [TrackInfo]) -> Option<&'a str> {
let audio_tracks: Vec<&TrackInfo> = tracks.iter().filter(|t| t.track_type == "audio").collect();
if audio_tracks.is_empty() {
return None;
}
// 1. If default track can be determined, use it
if let Some(dt) = audio_tracks.iter().find(|t| t.is_default) {
return Some(&dt.id);
}
// 2. Default cannot be determined: try English first
if let Some(en) = audio_tracks.iter().find(|t| is_english_lang(&t.lang, &t.title)) {
return Some(&en.id);
}
// 3. Then Dutch
if let Some(nl) = audio_tracks.iter().find(|t| is_dutch_lang(&t.lang, &t.title)) {
return Some(&nl.id);
}
// 4. Fallback to first available audio track
Some(&audio_tracks[0].id)
}
pub fn select_subtitle_track<'a>(tracks: &'a [TrackInfo]) -> Option<&'a str> {
let sub_tracks: Vec<&TrackInfo> = tracks.iter().filter(|t| t.track_type == "sub").collect();
if sub_tracks.is_empty() {
return None;
}
// Prefer Dutch subtitles first (non-forced if available)
let dutch_sub = sub_tracks.iter().find(|t| is_dutch_lang(&t.lang, &t.title) && !t.is_forced)
.or_else(|| sub_tracks.iter().find(|t| is_dutch_lang(&t.lang, &t.title)));
if let Some(nl) = dutch_sub {
return Some(&nl.id);
}
// Otherwise English subtitles (non-forced if available)
let english_sub = sub_tracks.iter().find(|t| is_english_lang(&t.lang, &t.title) && !t.is_forced)
.or_else(|| sub_tracks.iter().find(|t| is_english_lang(&t.lang, &t.title)));
if let Some(en) = english_sub {
return Some(&en.id);
}
None
}
#[derive(Clone, Copy)]
@@ -135,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),
@@ -145,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,
@@ -185,10 +370,13 @@ impl PlayerController {
mpv.set_option_string("vo", "libmpv");
mpv.set_option_string("hwdec", &config.hwdec);
mpv.set_option_string("idle", "yes");
mpv.set_option_string("keep-open", "yes");
mpv.set_option_string("keep-open", "no");
mpv.set_option_string("osd-bar", "no");
mpv.set_option_string("osd-level", "0");
mpv.set_option_string("ytdl", "yes");
mpv.set_option_string("alang", "en,eng,nl,nld,dut");
mpv.set_option_string("slang", "nl,nld,dut,en,eng");
mpv.set_option_string("subs-with-matching-audio", "yes");
let ytdlp_path = if std::path::Path::new("/usr/local/bin/yt-dlp").exists() {
"/usr/local/bin/yt-dlp"
@@ -218,6 +406,10 @@ impl PlayerController {
libmpv_sys::mpv_observe_property(raw_mpv, 6, b"icy-title\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_STRING);
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));
@@ -235,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;
@@ -265,46 +475,110 @@ 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.artist == "Radio Chicken DJ" || mt.contains("Radio Chicken DJ") {
if !st.stream_title.is_empty() {
st.stream_title.clear();
notify = true;
}
} else 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;
st.media_type = "stream".to_string();
notify = true;
if !is_url_or_query_string(&icy) {
if st.artist == "Radio Chicken DJ" || icy.contains("Radio Chicken DJ") {
if !st.stream_title.is_empty() {
st.stream_title.clear();
notify = true;
}
} else if 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 st.artist == "Radio Chicken DJ" || s.contains("Radio Chicken DJ") {
if !st.stream_title.is_empty() {
st.stream_title.clear();
notify = true;
}
} else 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 {
@@ -324,11 +598,14 @@ impl PlayerController {
st.error_message = Some(err_msg);
notify = true;
} else if reason == libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_EOF as i32 {
tracing::info!("MPV playback finished (EOF reached) -> returning to stopped state");
st.state = PlaybackState::Stopped;
st.time_pos = 0.0;
st.duration = 0.0;
st.media_type.clear();
st.title.clear();
st.artist.clear();
st.playlist.clear();
st.genre.clear();
st.stream_title.clear();
notify = true;
@@ -427,6 +704,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"
@@ -450,6 +745,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();
@@ -474,9 +772,10 @@ impl PlayerController {
url.to_string()
};
// Always reset video and audio track selection to auto so subsequent content plays normally
// Always reset video, audio, and subtitle track selection so subsequent content plays cleanly
self.mpv.cmd(&["set", "vid", "auto"]);
self.mpv.cmd(&["set", "aid", "auto"]);
self.mpv.cmd(&["set", "sid", "auto"]);
self.mpv.cmd(&["loadfile", &play_url, "replace"]);
Ok(())
}
@@ -490,9 +789,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;
@@ -579,6 +882,120 @@ 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();
if song.is_tts || song.artist == "Radio Chicken DJ" {
st.stream_title.clear();
} else if let Some(ref st_title) = song.stream_title {
st.stream_title = st_title.clone();
} else {
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='{}', stream_title='{}'",
song.artist,
song.name,
playlist_name,
song.genre,
if song.is_tts || song.artist == "Radio Chicken DJ" { "" } else { &song.name }
);
}
}
}
}
}
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 stream_title: Option<String>,
#[serde(default)]
pub is_tts: bool,
}
impl Drop for PlayerController {
@@ -637,10 +1054,16 @@ fn run_mpv_event_loop(
}
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_FILE_LOADED {
mpv.auto_select_tracks();
let _ = event_tx.send(InternalMpvEvent::FileLoaded);
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() {
@@ -726,6 +1149,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 {
@@ -741,6 +1165,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());
}
@@ -749,14 +1175,271 @@ 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 });
}
}
}
}
"eof-reached" => {
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_FLAG } && !unsafe { (*prop).data.is_null() } {
let eof = unsafe { *((*prop).data as *const std::ffi::c_int) != 0 };
if eof {
let _ = event_tx.send(InternalMpvEvent::EndFile {
reason: libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_EOF as i32,
error: 0,
});
}
}
}
"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));
}
}
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_english_and_dutch_lang() {
assert!(is_english_lang("en", ""));
assert!(is_english_lang("eng", ""));
assert!(is_english_lang("en-US", ""));
assert!(is_english_lang("", "English (Stereo)"));
assert!(is_english_lang("", "Engels 5.1"));
assert!(!is_english_lang("nl", ""));
assert!(is_dutch_lang("nl", ""));
assert!(is_dutch_lang("nld", ""));
assert!(is_dutch_lang("dut", ""));
assert!(is_dutch_lang("", "Nederlands"));
assert!(is_dutch_lang("", "Dutch Surround"));
assert!(!is_dutch_lang("en", ""));
}
#[test]
fn test_select_audio_track_with_default() {
let tracks = vec![
TrackInfo {
id: "1".into(),
track_type: "audio".into(),
lang: "fra".into(),
title: "French".into(),
is_default: true,
is_forced: false,
is_selected: false,
},
TrackInfo {
id: "2".into(),
track_type: "audio".into(),
lang: "eng".into(),
title: "English".into(),
is_default: false,
is_forced: false,
is_selected: false,
},
];
// When default is determined, it picks the default track (id "1")
assert_eq!(select_audio_track(&tracks), Some("1"));
}
#[test]
fn test_select_audio_track_no_default_english_preferred() {
let tracks = vec![
TrackInfo {
id: "1".into(),
track_type: "audio".into(),
lang: "jpn".into(),
title: "Japanese".into(),
is_default: false,
is_forced: false,
is_selected: false,
},
TrackInfo {
id: "2".into(),
track_type: "audio".into(),
lang: "nld".into(),
title: "Nederlands".into(),
is_default: false,
is_forced: false,
is_selected: false,
},
TrackInfo {
id: "3".into(),
track_type: "audio".into(),
lang: "eng".into(),
title: "English".into(),
is_default: false,
is_forced: false,
is_selected: false,
},
];
// No default: English is preferred before Dutch
assert_eq!(select_audio_track(&tracks), Some("3"));
}
#[test]
fn test_select_audio_track_no_default_dutch_when_no_english() {
let tracks = vec![
TrackInfo {
id: "1".into(),
track_type: "audio".into(),
lang: "jpn".into(),
title: "Japanese".into(),
is_default: false,
is_forced: false,
is_selected: false,
},
TrackInfo {
id: "2".into(),
track_type: "audio".into(),
lang: "nld".into(),
title: "Nederlands".into(),
is_default: false,
is_forced: false,
is_selected: false,
},
];
// No default, no English: Dutch is chosen
assert_eq!(select_audio_track(&tracks), Some("2"));
}
#[test]
fn test_select_subtitle_track_dutch_preferred_over_english() {
let tracks = vec![
TrackInfo {
id: "10".into(),
track_type: "sub".into(),
lang: "eng".into(),
title: "English".into(),
is_default: true,
is_forced: false,
is_selected: false,
},
TrackInfo {
id: "11".into(),
track_type: "sub".into(),
lang: "nld".into(),
title: "Nederlands".into(),
is_default: false,
is_forced: false,
is_selected: false,
},
];
// Dutch subtitles preferred over English
assert_eq!(select_subtitle_track(&tracks), Some("11"));
}
#[test]
fn test_select_subtitle_track_english_fallback_when_no_dutch() {
let tracks = vec![
TrackInfo {
id: "10".into(),
track_type: "sub".into(),
lang: "fra".into(),
title: "French".into(),
is_default: false,
is_forced: false,
is_selected: false,
},
TrackInfo {
id: "11".into(),
track_type: "sub".into(),
lang: "eng".into(),
title: "English".into(),
is_default: false,
is_forced: false,
is_selected: false,
},
];
// No Dutch subtitles: English is selected
assert_eq!(select_subtitle_track(&tracks), Some("11"));
}
#[test]
fn test_select_subtitle_track_none_when_neither_dutch_nor_english() {
let tracks = vec![
TrackInfo {
id: "10".into(),
track_type: "sub".into(),
lang: "fra".into(),
title: "French".into(),
is_default: false,
is_forced: false,
is_selected: false,
},
];
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);
}
}
+246 -70
View File
@@ -177,6 +177,13 @@ pub fn create_yatse_router(state: YatseServerState) -> Router {
Router::new()
.route("/", get(handle_root))
.route("/jsonrpc", post(handle_jsonrpc).get(handle_jsonrpc_get))
.route("/image", get(handle_image))
.route("/image/", get(handle_image))
.route("/image/*path", get(handle_image))
.route("/vfs", get(handle_image))
.route("/vfs/", get(handle_image))
.route("/vfs/*path", get(handle_image))
.fallback(handle_fallback)
.with_state(state)
}
@@ -184,7 +191,120 @@ async fn handle_root() -> impl IntoResponse {
"Jellyfin HTPC Kiosk (Rust) - Kodi JSON-RPC Server for Yatse"
}
fn percent_decode(input: &str) -> String {
pub fn percent_encode(s: &str) -> String {
let mut out = String::new();
for b in s.as_bytes() {
if b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_' || *b == b'.' || *b == b'~' {
out.push(*b as char);
} else {
out.push_str(&format!("%{:02x}", b));
}
}
out
}
pub fn to_kodi_art(url: &str) -> String {
if url.is_empty() {
return String::new();
}
if url.starts_with("image://") {
return url.to_string();
}
format!("image://{}/", percent_encode(url))
}
pub fn extract_image_url(uri_path: &str, query: Option<&str>) -> Option<String> {
let mut s = uri_path;
for pfx in ["/image/", "/image", "/vfs/", "/vfs"] {
if s.starts_with(pfx) {
s = &s[pfx.len()..];
break;
}
}
let mut cur = s.to_string();
for _ in 0..3 {
let decoded = percent_decode(&cur);
let trimmed = decoded.trim().trim_end_matches('/');
if let Some(inner) = trimmed.strip_prefix("image://") {
cur = inner.trim_end_matches('/').to_string();
continue;
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
cur = trimmed.to_string();
break;
}
cur = decoded;
}
if cur.starts_with("http://") || cur.starts_with("https://") {
if let Some(q) = query {
if !cur.contains('?') {
cur = format!("{}?{}", cur, q);
}
}
if cur.contains('?') {
cur = cur.trim_end_matches('/').to_string();
}
return Some(cur);
}
None
}
async fn handle_image(
State(state): State<YatseServerState>,
uri: axum::http::Uri,
) -> impl IntoResponse {
let path = uri.path();
let query = uri.query();
if let Some(target_url) = extract_image_url(path, query) {
tracing::info!("Proxying Kodi image: {} -> {}", path, target_url);
let client = reqwest::Client::new();
let mut req = client.get(&target_url);
if let Some(token) = state.client.get_token().await {
req = req.header("X-Emby-Token", token);
}
match req.send().await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
let content_type = resp.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
if let Ok(bytes) = resp.bytes().await {
return (
axum::http::StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type),
(axum::http::header::CACHE_CONTROL, "public, max-age=31536000".to_string()),
],
bytes,
).into_response();
}
} else {
tracing::warn!("Failed to fetch image from Jellyfin: status={} url={}", status, target_url);
}
}
Err(e) => {
tracing::warn!("Error requesting image from Jellyfin: {} url={}", e, target_url);
}
}
} else {
tracing::warn!("Could not extract image URL from uri: path={} query={:?}", path, query);
}
(axum::http::StatusCode::NOT_FOUND, "Image not found").into_response()
}
async fn handle_fallback(
State(state): State<YatseServerState>,
uri: axum::http::Uri,
) -> impl IntoResponse {
handle_image(State(state), uri).await
}
pub fn percent_decode(input: &str) -> String {
let mut bytes = Vec::new();
let mut chars = input.bytes();
while let Some(b) = chars.next() {
@@ -1208,6 +1328,38 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"id": id
})
}
"Files.PrepareDownload" => {
let path_opt = req.params.as_ref()
.and_then(|p| p.get("path").or_else(|| p.as_array().and_then(|a| a.first())))
.and_then(|s| s.as_str());
if let Some(path) = path_opt {
let encoded = percent_encode(path);
json!({
"jsonrpc": "2.0",
"result": {
"details": {
"path": format!("image/{}", encoded)
},
"mode": "redirect",
"protocol": "http"
},
"id": id
})
} else {
json!({
"jsonrpc": "2.0",
"result": {
"details": {
"path": ""
},
"mode": "redirect",
"protocol": "http"
},
"id": id
})
}
}
"VideoLibrary.GetMovies" => {
let lib = state.client.get_library().await;
let base_url = &state.client.config.server_url;
@@ -1230,12 +1382,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"playcount": playcount,
"genre": genres,
"file": format!("jellyfin://movie/{}", m.id),
"thumbnail": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumbnail": to_kodi_art(&m.thumb_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": m.thumb_url(base_url),
"poster": to_kodi_art(&m.poster_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"thumb": to_kodi_art(&m.thumb_url(base_url)),
},
"resume": {
"position": 0.0,
@@ -1281,12 +1433,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"playcount": playcount,
"genre": genres,
"file": format!("jellyfin://movie/{}", m.id),
"thumbnail": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumbnail": to_kodi_art(&m.thumb_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": m.thumb_url(base_url),
"poster": to_kodi_art(&m.poster_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"thumb": to_kodi_art(&m.thumb_url(base_url)),
},
"resume": {
"position": 0.0,
@@ -1326,12 +1478,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"playcount": playcount,
"genre": genres,
"file": format!("jellyfin://movie/{}", m.id),
"thumbnail": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumbnail": to_kodi_art(&m.thumb_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": m.thumb_url(base_url),
"poster": to_kodi_art(&m.poster_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"thumb": to_kodi_art(&m.thumb_url(base_url)),
}
}));
}
@@ -1368,12 +1520,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"season": s.child_count.unwrap_or(1),
"watchedepisodes": 0,
"file": format!("jellyfin://show/{}", s.id),
"thumbnail": s.thumb_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumbnail": to_kodi_art(&s.thumb_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url)),
"art": {
"poster": s.poster_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumb": s.thumb_url(base_url),
"poster": to_kodi_art(&s.poster_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url)),
"thumb": to_kodi_art(&s.thumb_url(base_url)),
}
}));
}
@@ -1414,12 +1566,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"season": s.child_count.unwrap_or(1),
"watchedepisodes": 0,
"file": format!("jellyfin://show/{}", s.id),
"thumbnail": s.thumb_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumbnail": to_kodi_art(&s.thumb_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url)),
"art": {
"poster": s.poster_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumb": s.thumb_url(base_url),
"poster": to_kodi_art(&s.poster_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url)),
"thumb": to_kodi_art(&s.thumb_url(base_url)),
}
});
}
@@ -1475,12 +1627,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"label": format!("Seizoen {}", s_num),
"episode": ep_count,
"watchedepisodes": 0,
"thumbnail": poster,
"fanart": backdrop,
"thumbnail": to_kodi_art(&poster),
"fanart": to_kodi_art(&backdrop),
"art": {
"poster": poster,
"fanart": backdrop,
"thumb": poster
"poster": to_kodi_art(&poster),
"fanart": to_kodi_art(&backdrop),
"thumb": to_kodi_art(&poster)
}
}));
}
@@ -1557,12 +1709,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"rating": ep.rating.unwrap_or(0.0),
"playcount": playcount,
"file": format!("jellyfin://episode/{}", ep.id),
"thumbnail": ep.thumb_url(base_url),
"fanart": ep.backdrop_url(base_url),
"thumbnail": to_kodi_art(&ep.thumb_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
"art": {
"thumb": ep.thumb_url(base_url),
"poster": ep.poster_url(base_url),
"fanart": ep.backdrop_url(base_url),
"thumb": to_kodi_art(&ep.thumb_url(base_url)),
"poster": to_kodi_art(&ep.poster_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
}
}));
}
@@ -1606,12 +1758,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"rating": ep.rating.unwrap_or(0.0),
"playcount": playcount,
"file": format!("jellyfin://episode/{}", ep.id),
"thumbnail": ep.thumb_url(base_url),
"fanart": ep.backdrop_url(base_url),
"thumbnail": to_kodi_art(&ep.thumb_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
"art": {
"thumb": ep.thumb_url(base_url),
"poster": ep.poster_url(base_url),
"fanart": ep.backdrop_url(base_url),
"thumb": to_kodi_art(&ep.thumb_url(base_url)),
"poster": to_kodi_art(&ep.poster_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
}
}));
}
@@ -1653,12 +1805,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"rating": ep.rating.unwrap_or(0.0),
"playcount": playcount,
"file": format!("jellyfin://episode/{}", ep.id),
"thumbnail": ep.thumb_url(base_url),
"fanart": ep.backdrop_url(base_url),
"thumbnail": to_kodi_art(&ep.thumb_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
"art": {
"thumb": ep.thumb_url(base_url),
"poster": ep.poster_url(base_url),
"fanart": ep.backdrop_url(base_url),
"thumb": to_kodi_art(&ep.thumb_url(base_url)),
"poster": to_kodi_art(&ep.poster_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
}
});
}
@@ -1702,11 +1854,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"artistid": aid,
"artist": a.name,
"label": a.name,
"thumbnail": a.square_url(base_url),
"fanart": a.backdrop_url(base_url),
"thumbnail": to_kodi_art(&a.square_url(base_url)),
"fanart": to_kodi_art(&a.backdrop_url(base_url)),
"art": {
"thumb": a.square_url(base_url),
"fanart": a.backdrop_url(base_url),
"thumb": to_kodi_art(&a.square_url(base_url)),
"fanart": to_kodi_art(&a.backdrop_url(base_url)),
}
}));
}
@@ -1734,11 +1886,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"label": al.name,
"artist": al.artist_name.as_deref().unwrap_or(""),
"year": al.year.unwrap_or(0),
"thumbnail": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"thumbnail": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
"art": {
"thumb": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"thumb": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
}
}));
}
@@ -1766,11 +1918,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"label": al.name,
"artist": al.artist_name.as_deref().unwrap_or(""),
"year": al.year.unwrap_or(0),
"thumbnail": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"thumbnail": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
"art": {
"thumb": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"thumb": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
}
}));
}
@@ -1831,11 +1983,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"disc": disc,
"year": s.year.unwrap_or(0),
"file": file,
"thumbnail": s.square_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumbnail": to_kodi_art(&s.square_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url)),
"art": {
"thumb": s.square_url(base_url),
"fanart": s.backdrop_url(base_url)
"thumb": to_kodi_art(&s.square_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url))
}
}));
}
@@ -1889,11 +2041,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"label": al.name,
"artist": al.artist_name.as_deref().unwrap_or(""),
"year": al.year.unwrap_or(0),
"thumbnail": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"thumbnail": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
"art": {
"thumb": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"thumb": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
}
});
}
@@ -1922,11 +2074,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"artistid": aid,
"artist": ar.name,
"label": ar.name,
"thumbnail": ar.square_url(base_url),
"fanart": ar.backdrop_url(base_url),
"thumbnail": to_kodi_art(&ar.square_url(base_url)),
"fanart": to_kodi_art(&ar.backdrop_url(base_url)),
"art": {
"thumb": ar.square_url(base_url),
"fanart": ar.backdrop_url(base_url),
"thumb": to_kodi_art(&ar.square_url(base_url)),
"fanart": to_kodi_art(&ar.backdrop_url(base_url)),
}
});
}
@@ -2222,4 +2374,28 @@ mod tests {
"https://youtube.com/watch?v=eMEIt6_mGSI&is=hEdy88z6Nhf7gRSE"
);
}
#[test]
fn test_extract_image_url_variations() {
let original = "http://10.2.0.4:8096/jellyfin/Items/125c/Images/Primary?fillWidth=320&fillHeight=480&quality=85";
let kodi_art = to_kodi_art(original);
assert!(kodi_art.starts_with("image://"));
assert!(kodi_art.ends_with('/'));
// Case 1: Yatse calls /image/<encoded>
let path1 = format!("/image/{}", percent_encode(&kodi_art));
let res1 = extract_image_url(&path1, None).expect("extracted 1");
assert_eq!(res1, original);
// Case 2: Yatse calls /image/<single-encoded-http-with-trailing-slash>
let path2 = format!("/image/{}/", percent_encode(original));
let res2 = extract_image_url(&path2, None).expect("extracted 2");
assert_eq!(res2, original);
// Case 3: Yatse calls /vfs/<kodi-art>
let path3 = format!("/vfs/{}", percent_encode(&kodi_art));
let res3 = extract_image_url(&path3, None).expect("extracted 3");
assert_eq!(res3, original);
}
}
@@ -0,0 +1 @@
{"rustc_fingerprint":14753936209423482788,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/bart/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""}},"successes":{}}
+3
View File
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
3ac0e9726997097b
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"gvar-alloc\", \"std\", \"variable-fonts\"]","declared_features":"[\"default\", \"gvar-alloc\", \"libm\", \"std\", \"variable-fonts\"]","target":11794240345726188307,"profile":2241668132362809309,"path":5649524171242691192,"deps":[[4945662571602681759,"ab_glyph_rasterizer",false,2687199477971215692],[5327495677235252177,"owned_ttf_parser",false,13558008051659722080]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ab_glyph-0217f9255a6590dd/dep-lib-ab_glyph","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
5d1ec60264864a72
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"gvar-alloc\", \"std\", \"variable-fonts\"]","declared_features":"[\"default\", \"gvar-alloc\", \"libm\", \"std\", \"variable-fonts\"]","target":11794240345726188307,"profile":15657897354478470176,"path":5649524171242691192,"deps":[[4945662571602681759,"ab_glyph_rasterizer",false,15637680591314221340],[5327495677235252177,"owned_ttf_parser",false,9976818268436013839]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ab_glyph-351e6496e093a624/dep-lib-ab_glyph","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"libm\", \"std\"]","target":4335109392423587462,"profile":2241668132362809309,"path":13818387167479311948,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ab_glyph_rasterizer-34531215e91a4783/dep-lib-ab_glyph_rasterizer","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"libm\", \"std\"]","target":4335109392423587462,"profile":15657897354478470176,"path":13818387167479311948,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ab_glyph_rasterizer-e4817a80f37d7712/dep-lib-ab_glyph_rasterizer","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
699c6ac28aa5af89
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"core\", \"default\", \"rustc-dep-of-std\", \"std\"]","target":6569825234462323107,"profile":15657897354478470176,"path":4529919677372112567,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/adler2-5305f511e1c31af3/dep-lib-adler2","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
892547091c8cf972
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"core\", \"default\", \"rustc-dep-of-std\", \"std\"]","target":6569825234462323107,"profile":2241668132362809309,"path":4529919677372112567,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/adler2-b5185ec3be97cc68/dep-lib-adler2","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
5068cea52b295da9
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"getrandom\", \"no-rng\", \"runtime-rng\", \"std\"]","declared_features":"[\"atomic-polyfill\", \"compile-time-rng\", \"const-random\", \"default\", \"getrandom\", \"nightly-arm-aes\", \"no-rng\", \"runtime-rng\", \"serde\", \"std\"]","target":8470944000320059508,"profile":15657897354478470176,"path":3288015079357928609,"deps":[[966925859616469517,"build_script_build",false,9203697050838375196],[4321869508056025743,"zerocopy",false,2467696241657189483],[5855319743879205494,"once_cell",false,12280998255132305995],[15482175856213997617,"cfg_if",false,14176503484701607164],[18408407127522236545,"getrandom",false,2733066888145013121]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ahash-2b16cca8b306abc9/dep-lib-ahash","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
842172d1ed01f74c
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"getrandom\", \"no-rng\", \"runtime-rng\", \"std\"]","declared_features":"[\"atomic-polyfill\", \"compile-time-rng\", \"const-random\", \"default\", \"getrandom\", \"nightly-arm-aes\", \"no-rng\", \"runtime-rng\", \"serde\", \"std\"]","target":8470944000320059508,"profile":2241668132362809309,"path":3288015079357928609,"deps":[[966925859616469517,"build_script_build",false,9203697050838375196],[4321869508056025743,"zerocopy",false,15765362044039701013],[5855319743879205494,"once_cell",false,11850536697227471877],[15482175856213997617,"cfg_if",false,14735624068320645977],[18408407127522236545,"getrandom",false,15986369605517680036]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ahash-a3e6e2b710a4e0bc/dep-lib-ahash","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"","declared_features":"","target":0,"profile":0,"path":0,"deps":[[966925859616469517,"build_script_build",false,17893360845909163615]],"local":[{"RerunIfChanged":{"output":"debug/build/ahash-a57948a68d40e0a7/output","paths":["build.rs"]}}],"rustflags":[],"config":0,"compile_kind":0}
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"default\", \"getrandom\", \"no-rng\", \"runtime-rng\", \"std\"]","declared_features":"[\"atomic-polyfill\", \"compile-time-rng\", \"const-random\", \"default\", \"getrandom\", \"nightly-arm-aes\", \"no-rng\", \"runtime-rng\", \"serde\", \"std\"]","target":17883862002600103897,"profile":2225463790103693989,"path":4299570802035626402,"deps":[[5398981501050481332,"version_check",false,14943717254255103075]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/ahash-d6dd36af1ebc73ff/dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
181ee43004d5c945
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"core-graphics\", \"default\", \"image\", \"image-data\", \"wayland-data-control\", \"windows-sys\", \"wl-clipboard-rs\"]","target":1337616771932055151,"profile":2241668132362809309,"path":17166859248196286572,"deps":[[6803352382179706244,"percent_encoding",false,7822495553327927090],[7264839661287455353,"log",false,12557639338793902813],[12459942763388630573,"parking_lot",false,17310600078098908667],[15803581142294733505,"x11rb",false,15864593136328662872]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/arboard-4d99d111aeeb9d85/dep-lib-arboard","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
941081f2db9ef8de
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"core-graphics\", \"default\", \"image\", \"image-data\", \"wayland-data-control\", \"windows-sys\", \"wl-clipboard-rs\"]","target":1337616771932055151,"profile":15657897354478470176,"path":17166859248196286572,"deps":[[6803352382179706244,"percent_encoding",false,10204264024909671241],[7264839661287455353,"log",false,10968825162537122904],[12459942763388630573,"parking_lot",false,7493661607571755906],[15803581142294733505,"x11rb",false,16408250448247954789]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/arboard-941d3c3f22ae1264/dep-lib-arboard","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"alloc\", \"default\"]","declared_features":"[\"alloc\", \"default\"]","target":8427501830925002634,"profile":2241668132362809309,"path":11315664722530468864,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/as-raw-xcb-connection-1b429b60928621fa/dep-lib-as_raw_xcb_connection","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"alloc\", \"default\"]","declared_features":"[\"alloc\", \"default\"]","target":8427501830925002634,"profile":15657897354478470176,"path":11315664722530468864,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/as-raw-xcb-connection-9d9025af637da936/dep-lib-as_raw_xcb_connection","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[]","target":5116616278641129243,"profile":2225463790103693989,"path":8469429350005984183,"deps":[[8711674966389384079,"syn",false,12098035866327759596],[8949245912927223590,"quote",false,6701396820762924173],[16346726298725429545,"proc_macro2",false,12397529350165103128]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/async-trait-b09e65b0c30ab584/dep-lib-async_trait","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":15657897354478470176,"path":551700150563500949,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atomic-waker-199214763a0024c7/dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":2241668132362809309,"path":551700150563500949,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atomic-waker-96e688c59e310096/dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
1fd5c4355ae3a430
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[]","target":6962977057026645649,"profile":2225463790103693989,"path":6403542389053020766,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/autocfg-374b6208e55aaac6/dep-lib-autocfg","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
ef0aa26d005fff9d
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"http1\", \"json\", \"tokio\"]","declared_features":"[\"__private_docs\", \"default\", \"form\", \"http1\", \"http2\", \"json\", \"macros\", \"matched-path\", \"multipart\", \"original-uri\", \"query\", \"tokio\", \"tower-log\", \"tracing\", \"ws\"]","target":13920321295547257648,"profile":15657897354478470176,"path":5480532092245258048,"deps":[[784494742817713399,"tower_service",false,7096277064673234537],[927329442006724342,"http_body_util",false,10169796446186527904],[2251399859588827949,"pin_project_lite",false,16496052122936131536],[2517136641825875337,"sync_wrapper",false,5993311283381840508],[3632162862999675140,"tower",false,5005464751011667245],[4359148418957042248,"axum_core",false,8050097667477050567],[5532778797167691009,"itoa",false,12625258790664933256],[6444209561448300374,"futures_util",false,2711510235064628060],[6557439603276904804,"serde",false,10117669846973571935],[6803352382179706244,"percent_encoding",false,10204264024909671241],[7712452662827335977,"tower_layer",false,1094941957052667426],[8160210889872729633,"serde_json",false,1366118035012243637],[9678799920983747518,"matchit",false,466840185268934315],[10229185211513642314,"mime",false,3488359487242993514],[10260941683582100114,"async_trait",false,15969331622671879673],[11926622812581095017,"bytes",false,14261233726640694094],[12328341851100645683,"http",false,3354019498251889263],[12613788554453945248,"memchr",false,14549996354126691281],[14092367075979712649,"hyper",false,11409751076288742659],[14814583949208169760,"serde_path_to_error",false,3598317744880925089],[15618961772992676818,"hyper_util",false,5909608442022453208],[16991438365634268121,"rustversion",false,3477745542916629179],[17866805608252134857,"tokio",false,10735189983294870288],[17905774625381964326,"http_body",false,8880207948320647891]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/axum-05a9f8609164393f/dep-lib-axum","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
a0c307ffee1dce9e
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"__private_docs\", \"tracing\"]","target":2565713999752801252,"profile":2241668132362809309,"path":4251269609799930624,"deps":[[784494742817713399,"tower_service",false,8641590773704259889],[927329442006724342,"http_body_util",false,17754582866654415635],[2251399859588827949,"pin_project_lite",false,6123724137355080315],[2517136641825875337,"sync_wrapper",false,6298301960919763486],[6444209561448300374,"futures_util",false,9044899930474534103],[7712452662827335977,"tower_layer",false,11366169840293730998],[10229185211513642314,"mime",false,1190444657937747181],[10260941683582100114,"async_trait",false,15969331622671879673],[11926622812581095017,"bytes",false,10182236420260745664],[12328341851100645683,"http",false,147347367879140718],[16991438365634268121,"rustversion",false,3477745542916629179],[17905774625381964326,"http_body",false,8442883690053730452]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/axum-core-64fa7952c1b326c0/dep-lib-axum_core","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
c7942b652db1b76f
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[]","declared_features":"[\"__private_docs\", \"tracing\"]","target":2565713999752801252,"profile":15657897354478470176,"path":4251269609799930624,"deps":[[784494742817713399,"tower_service",false,7096277064673234537],[927329442006724342,"http_body_util",false,10169796446186527904],[2251399859588827949,"pin_project_lite",false,16496052122936131536],[2517136641825875337,"sync_wrapper",false,5993311283381840508],[6444209561448300374,"futures_util",false,2711510235064628060],[7712452662827335977,"tower_layer",false,1094941957052667426],[10229185211513642314,"mime",false,3488359487242993514],[10260941683582100114,"async_trait",false,15969331622671879673],[11926622812581095017,"bytes",false,14261233726640694094],[12328341851100645683,"http",false,3354019498251889263],[16991438365634268121,"rustversion",false,3477745542916629179],[17905774625381964326,"http_body",false,8880207948320647891]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/axum-core-ef7cc4f7dfec6902/dep-lib-axum_core","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
6e25403a230c2947
@@ -0,0 +1 @@
{"rustc":7458672600737419911,"features":"[\"http1\", \"json\", \"tokio\"]","declared_features":"[\"__private_docs\", \"default\", \"form\", \"http1\", \"http2\", \"json\", \"macros\", \"matched-path\", \"multipart\", \"original-uri\", \"query\", \"tokio\", \"tower-log\", \"tracing\", \"ws\"]","target":13920321295547257648,"profile":2241668132362809309,"path":5480532092245258048,"deps":[[784494742817713399,"tower_service",false,8641590773704259889],[927329442006724342,"http_body_util",false,17754582866654415635],[2251399859588827949,"pin_project_lite",false,6123724137355080315],[2517136641825875337,"sync_wrapper",false,6298301960919763486],[3632162862999675140,"tower",false,6705119147173761980],[4359148418957042248,"axum_core",false,11443116615513129888],[5532778797167691009,"itoa",false,13304641298247861803],[6444209561448300374,"futures_util",false,9044899930474534103],[6557439603276904804,"serde",false,7841406684811627843],[6803352382179706244,"percent_encoding",false,7822495553327927090],[7712452662827335977,"tower_layer",false,11366169840293730998],[8160210889872729633,"serde_json",false,2833392396230018759],[9678799920983747518,"matchit",false,15382285630579014378],[10229185211513642314,"mime",false,1190444657937747181],[10260941683582100114,"async_trait",false,15969331622671879673],[11926622812581095017,"bytes",false,10182236420260745664],[12328341851100645683,"http",false,147347367879140718],[12613788554453945248,"memchr",false,6094110374646518287],[14092367075979712649,"hyper",false,2757493669660138066],[14814583949208169760,"serde_path_to_error",false,2322671378224961024],[15618961772992676818,"hyper_util",false,16482928109375956042],[16991438365634268121,"rustversion",false,3477745542916629179],[17866805608252134857,"tokio",false,12111369162276798285],[17905774625381964326,"http_body",false,8442883690053730452]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/axum-d810a780cd9dc547/dep-lib-axum","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.

Some files were not shown because too many files have changed in this diff Show More