eerste versie
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
response::{IntoResponse, Json},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use crate::player::{PlaybackState, PlayerController};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteNavAction {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Select,
|
||||
Back,
|
||||
ToggleOSD,
|
||||
CycleSubtitle,
|
||||
TogglePlaybackView,
|
||||
ActivateSection(usize),
|
||||
VolumeUp,
|
||||
VolumeDown,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct YatseServerState {
|
||||
pub player: Arc<PlayerController>,
|
||||
pub nav_tx: UnboundedSender<RemoteNavAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
#[allow(dead_code)]
|
||||
jsonrpc: Option<String>,
|
||||
method: String,
|
||||
params: Option<Value>,
|
||||
id: Option<Value>,
|
||||
}
|
||||
|
||||
pub fn create_yatse_router(state: YatseServerState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(handle_root))
|
||||
.route("/jsonrpc", post(handle_jsonrpc).get(handle_jsonrpc_get))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn handle_root() -> impl IntoResponse {
|
||||
"Jellyfin HTPC Kiosk (Rust) - Kodi JSON-RPC Server for Yatse"
|
||||
}
|
||||
|
||||
async fn handle_jsonrpc_get() -> impl IntoResponse {
|
||||
Json(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"version": {"major": 19, "minor": 0, "patch": 0}
|
||||
},
|
||||
"id": 1
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_jsonrpc(
|
||||
State(state): State<YatseServerState>,
|
||||
Json(req): Json<Value>,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(arr) = req.as_array() {
|
||||
let mut results = Vec::new();
|
||||
for item in arr {
|
||||
if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(item.clone()) {
|
||||
results.push(dispatch_rpc(&state, parsed).await);
|
||||
}
|
||||
}
|
||||
Json(Value::Array(results))
|
||||
} else if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(req) {
|
||||
let res = dispatch_rpc(&state, parsed).await;
|
||||
Json(res)
|
||||
} else {
|
||||
Json(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32700, "message": "Parse error"},
|
||||
"id": Value::Null
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
||||
let id = req.id.unwrap_or(Value::Null);
|
||||
let method = req.method.as_str();
|
||||
tracing::info!("Yatse JSON-RPC: method={}, params={:?}", method, req.params);
|
||||
|
||||
match method {
|
||||
"JSONRPC.Ping" => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": "pong",
|
||||
"id": id
|
||||
}),
|
||||
"JSONRPC.Version" => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {"version": {"major": 19, "minor": 0, "patch": 0}},
|
||||
"id": id
|
||||
}),
|
||||
"Application.GetProperties" => {
|
||||
let st = state.player.status.lock().await;
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"volume": st.volume as i64,
|
||||
"muted": st.is_muted,
|
||||
"name": "Kodi",
|
||||
"version": {"major": 19, "minor": 0, "revision": "20260401", "tag": "stable"},
|
||||
"language": "en_GB",
|
||||
"sorttokens": ["The ", "The.", "The_"]
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Application.SetMute" => {
|
||||
let mute_param = req.params.as_ref()
|
||||
.and_then(|p| p.get("mute").or_else(|| p.as_array().and_then(|a| a.first())))
|
||||
.and_then(|m| m.as_bool());
|
||||
|
||||
let is_muted = match mute_param {
|
||||
Some(m) => {
|
||||
state.player.set_mute(m).await;
|
||||
m
|
||||
}
|
||||
None => {
|
||||
state.player.toggle_mute().await;
|
||||
state.player.status.lock().await.is_muted
|
||||
}
|
||||
};
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": is_muted,
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Application.SetVolume" => {
|
||||
let vol_param = req.params.as_ref()
|
||||
.and_then(|p| p.get("volume").or_else(|| p.as_array().and_then(|a| a.first())))
|
||||
.and_then(|v| v.as_f64());
|
||||
|
||||
let new_vol = match vol_param {
|
||||
Some(v) => {
|
||||
state.player.set_volume(v).await;
|
||||
v.clamp(0.0, 100.0) as i64
|
||||
}
|
||||
None => {
|
||||
state.player.status.lock().await.volume as i64
|
||||
}
|
||||
};
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": new_vol,
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.GetActivePlayers" => {
|
||||
let st = state.player.status.lock().await;
|
||||
if st.state != PlaybackState::Stopped {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": [{"playerid": 1, "playertype": "internal", "type": "video"}],
|
||||
"id": id
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": [],
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
}
|
||||
"Player.GetProperties" => {
|
||||
let st = state.player.status.lock().await;
|
||||
let time = seconds_to_kodi_time(st.time_pos);
|
||||
let totaltime = seconds_to_kodi_time(st.duration);
|
||||
let speed = if st.state == PlaybackState::Playing { 1 } else { 0 };
|
||||
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"canseek": true,
|
||||
"speed": speed,
|
||||
"time": time,
|
||||
"totaltime": totaltime,
|
||||
"percentage": if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { 0.0 }
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.GetItem" => {
|
||||
let st = state.player.status.lock().await;
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"item": {
|
||||
"label": st.title,
|
||||
"title": st.title,
|
||||
"type": "movie"
|
||||
}
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.PlayPause" => {
|
||||
state.player.play_pause().await;
|
||||
let st = state.player.status.lock().await;
|
||||
let speed = if st.state == PlaybackState::Playing { 1 } else { 0 };
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {"speed": speed},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.Stop" => {
|
||||
state.player.stop().await;
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": "OK",
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.Seek" => {
|
||||
if let Some(params) = req.params {
|
||||
if let Some(sec) = params.get("value").and_then(|v| v.as_f64()) {
|
||||
state.player.seek(sec).await;
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {"percentage": 50.0},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.Open" => {
|
||||
if let Some(params) = req.params {
|
||||
if let Some(item) = params.get("item") {
|
||||
if let Some(file) = item.get("file").and_then(|f| f.as_str()) {
|
||||
let file_str = file.to_string();
|
||||
let p = state.player.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = p.play(&file_str, "Media from Yatse").await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": "OK",
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
// Remote D-Pad inputs forwarded directly to the on-screen UI
|
||||
"Input.Up" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Up);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.Down" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Down);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.Left" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Left);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.Right" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Right);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.Select" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Select);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.Back" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Back);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.ContextMenu" | "Input.Info" => {
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.ExecuteAction" => {
|
||||
let action = req.params.as_ref()
|
||||
.and_then(|p| p.get("action").or_else(|| p.as_array().and_then(|a| a.first())))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
match action.as_str() {
|
||||
"up" | "cursorup" => { let _ = state.nav_tx.send(RemoteNavAction::Up); }
|
||||
"down" | "cursordown" => { let _ = state.nav_tx.send(RemoteNavAction::Down); }
|
||||
"left" | "cursorleft" => { let _ = state.nav_tx.send(RemoteNavAction::Left); }
|
||||
"right" | "cursorright" => { let _ = state.nav_tx.send(RemoteNavAction::Right); }
|
||||
"select" | "enter" => { let _ = state.nav_tx.send(RemoteNavAction::Select); }
|
||||
"back" | "previousmenu" | "parentdir" | "close" => { let _ = state.nav_tx.send(RemoteNavAction::Back); }
|
||||
"togglefullscreen" => { let _ = state.nav_tx.send(RemoteNavAction::ToggleOSD); }
|
||||
"osd" | "nextsubtitle" => { let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); }
|
||||
"title" | "menu" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); }
|
||||
"volumeup" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::VolumeUp);
|
||||
let p = state.player.clone();
|
||||
tokio::spawn(async move { p.volume_up().await; });
|
||||
}
|
||||
"volumedown" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::VolumeDown);
|
||||
let p = state.player.clone();
|
||||
tokio::spawn(async move { p.volume_down().await; });
|
||||
}
|
||||
"playpause" | "pause" | "play" => { state.player.play_pause().await; }
|
||||
"stop" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Stop);
|
||||
state.player.stop().await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"GUI.ActivateWindow" => {
|
||||
if let Some(params) = req.params {
|
||||
let window = params.get("window").and_then(|w| w.as_str()).unwrap_or("").to_lowercase();
|
||||
match window.as_str() {
|
||||
"videos" | "video" | "movies" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(1)); }
|
||||
"tvshows" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(2)); }
|
||||
"music" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(3)); }
|
||||
"pictures" | "photos" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(4)); }
|
||||
"home" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(0)); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"XBMC.GetInfoBooleans" => {
|
||||
let mut res = serde_json::Map::new();
|
||||
let mut requested = Vec::new();
|
||||
|
||||
if let Some(ref params) = req.params {
|
||||
if let Some(arr) = params.get("booleans").and_then(|b| b.as_array()) {
|
||||
for b in arr {
|
||||
if let Some(s) = b.as_str() {
|
||||
requested.push(s.to_string());
|
||||
}
|
||||
}
|
||||
} else if let Some(arr) = params.as_array().and_then(|a| a.first()).and_then(|b| b.as_array()) {
|
||||
for b in arr {
|
||||
if let Some(s) = b.as_str() {
|
||||
requested.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for s in &requested {
|
||||
let val = match s.as_str() {
|
||||
"Services.EventServer" | "Services.Webserver" | "Services.UPnP" => true,
|
||||
"System.Platform.Linux" => true,
|
||||
s if s.starts_with("System.Platform.") => false,
|
||||
_ => true,
|
||||
};
|
||||
res.insert(s.clone(), json!(val));
|
||||
}
|
||||
|
||||
if res.is_empty() {
|
||||
res.insert("Services.EventServer".to_string(), json!(true));
|
||||
res.insert("Services.Webserver".to_string(), json!(true));
|
||||
res.insert("System.Platform.Linux".to_string(), json!(true));
|
||||
}
|
||||
json!({"jsonrpc": "2.0", "result": res, "id": id})
|
||||
}
|
||||
"XBMC.GetInfoLabels" => {
|
||||
let mut res = serde_json::Map::new();
|
||||
let mut requested = Vec::new();
|
||||
|
||||
if let Some(ref params) = req.params {
|
||||
if let Some(arr) = params.get("labels").and_then(|b| b.as_array()) {
|
||||
for b in arr {
|
||||
if let Some(s) = b.as_str() {
|
||||
requested.push(s.to_string());
|
||||
}
|
||||
}
|
||||
} else if let Some(arr) = params.as_array().and_then(|a| a.first()).and_then(|b| b.as_array()) {
|
||||
for b in arr {
|
||||
if let Some(s) = b.as_str() {
|
||||
requested.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for s in &requested {
|
||||
let val = match s.as_str() {
|
||||
"System.BuildVersion" => "19.4 (19.4.0) Kodi",
|
||||
"System.FriendlyName" => "HTPC",
|
||||
"System.KernelVersion" => "Linux 6.8.0",
|
||||
_ => "",
|
||||
};
|
||||
res.insert(s.clone(), json!(val));
|
||||
}
|
||||
json!({"jsonrpc": "2.0", "result": res, "id": id})
|
||||
}
|
||||
"Settings.GetSettingValue" => {
|
||||
let setting = req.params.as_ref()
|
||||
.and_then(|p| p.get("setting").or_else(|| p.as_array().and_then(|a| a.first())))
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let val = if setting.contains("port") {
|
||||
if setting.contains("es") { json!(9777) } else { json!(8080) }
|
||||
} else if setting.contains("passthrough") {
|
||||
// Audio passthrough is FALSE, so standard volume controls work!
|
||||
json!(false)
|
||||
} else {
|
||||
json!(true)
|
||||
};
|
||||
json!({"jsonrpc": "2.0", "result": {"value": val}, "id": id})
|
||||
}
|
||||
"Settings.GetSettings" => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"settings": [
|
||||
{"id": "services.eventserver", "value": true},
|
||||
{"id": "services.esenabled", "value": true},
|
||||
{"id": "services.esport", "value": 9777},
|
||||
{"id": "services.esallinterfaces", "value": true},
|
||||
{"id": "services.webserver", "value": true},
|
||||
{"id": "services.webserverport", "value": 8080},
|
||||
{"id": "audiooutput.passthrough", "value": false}
|
||||
]
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Settings.SetSettingValue" => {
|
||||
json!({"jsonrpc": "2.0", "result": true, "id": id})
|
||||
}
|
||||
"JSONRPC.Permission" => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"ControlGUI": true,
|
||||
"ControlNotify": true,
|
||||
"ControlPlayback": true,
|
||||
"ControlPower": true,
|
||||
"ControlSystem": true,
|
||||
"ExecuteAddon": true,
|
||||
"ManageFiles": true,
|
||||
"Navigate": true,
|
||||
"ReadData": true,
|
||||
"WriteData": true
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"GUI.GetProperties" => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"currentwindow": {"id": 10000, "label": "Home"},
|
||||
"fullscreen": true,
|
||||
"skin": {"id": "skin.estuary", "name": "Estuary"}
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"System.GetProperties" => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"canreboot": true,
|
||||
"canshutdown": true,
|
||||
"cansuspend": false
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"GUI.ShowNotification" => {
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
_ => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": "OK",
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn seconds_to_kodi_time(seconds: f64) -> Value {
|
||||
let s = seconds.max(0.0) as u64;
|
||||
let hours = s / 3600;
|
||||
let minutes = (s % 3600) / 60;
|
||||
let secs = s % 60;
|
||||
let millis = ((seconds - s as f64) * 1000.0) as u64;
|
||||
json!({
|
||||
"hours": hours,
|
||||
"minutes": minutes,
|
||||
"seconds": secs,
|
||||
"milliseconds": millis
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user