Compare commits
4
Commits
423bc75c94
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ad12ff14 | ||
|
|
9da6d3db27 | ||
|
|
4bb6fe007e | ||
|
|
a795c3d024 |
+1
-1
@@ -7,4 +7,4 @@ dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
jellyfin-kiosk-rs/target
|
||||
jellyfin-kiosk-rs/target*.log
|
||||
|
||||
Generated
+1
-1
@@ -1460,7 +1460,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jellyfin-kiosk-rs"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"eframe",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellyfin-kiosk-rs"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
Binary file not shown.
@@ -1,12 +0,0 @@
|
||||
[2m2026-11-05T21:13:59.466559Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs[0m[2m:[0m Starting Jellyfin HTPC Kiosk (Rust)...
|
||||
[2m2026-11-05T21:13:59.467206Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::config[0m[2m:[0m Loaded config from ../config.yaml
|
||||
[2m2026-11-05T21:13:59.474938Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::player[0m[2m:[0m libmpv initialized successfully (vo=libmpv, hwdec=auto)
|
||||
[2m2026-11-05T21:13:59.549439Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::yatse[0m[2m:[0m Yatse Kodi TCP JSON-RPC socket server listening on 0.0.0.0:9090
|
||||
[2m2026-11-05T21:13:59.549444Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs[0m[2m:[0m Yatse Kodi JSON-RPC server listening on http://0.0.0.0:8080
|
||||
[2m2026-11-05T21:13:59.549923Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::eventserver[0m[2m:[0m Kodi EventServer listening on UDP 0.0.0.0:9777
|
||||
[2m2026-11-05T21:13:59.551471Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs[0m[2m:[0m Published Zeroconf services for Yatse discovery: 'Kodi (bart-laptop)'
|
||||
[2m2026-11-05T21:13:59.691500Z[0m [32m INFO[0m [2mwinit::platform_impl::linux::x11::window[0m[2m:[0m Guessed window scale factor: 1
|
||||
[2m2026-11-05T21:13:59.729454Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::youtube[0m[2m:[0m Loaded cached YouTube token from youtube_token.json
|
||||
Cannot load libcuda.so.1
|
||||
[2m2026-11-05T21:13:59.854273Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::player[0m[2m:[0m libmpv OpenGL render context created successfully!
|
||||
[2m2026-11-05T21:13:59.916581Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::jellyfin[0m[2m:[0m Authenticated successfully with Jellyfin as bart
|
||||
@@ -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
@@ -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, ¤t_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, ¤t_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
@@ -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
@@ -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":{}}
|
||||
@@ -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/
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
3ac0e9726997097b
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
5d1ec60264864a72
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+1
@@ -0,0 +1 @@
|
||||
4ce51615b0d94a25
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+1
@@ -0,0 +1 @@
|
||||
1cf94d54a53304d9
|
||||
+1
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
+1
@@ -0,0 +1 @@
|
||||
1c1b8857b419ba7f
|
||||
+1
@@ -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}
|
||||
+1
@@ -0,0 +1 @@
|
||||
5f7e4ce2e2fc51f8
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
Binary file not shown.
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+1
@@ -0,0 +1 @@
|
||||
a7db3cf8b6df24b9
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+1
@@ -0,0 +1 @@
|
||||
b625b33410081032
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+1
@@ -0,0 +1 @@
|
||||
f90dc56d80769edd
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+1
@@ -0,0 +1 @@
|
||||
91e2751f87e8a81a
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+1
@@ -0,0 +1 @@
|
||||
3de3dc6a31a73318
|
||||
+1
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
a0c307ffee1dce9e
|
||||
+1
@@ -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}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
c7942b652db1b76f
|
||||
+1
@@ -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}
|
||||
Binary file not shown.
@@ -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}
|
||||
Binary file not shown.
@@ -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
Reference in New Issue
Block a user