eerste versie

This commit is contained in:
bart
2026-11-03 22:21:03 +01:00
parent 8a08620866
commit 99cbc00357
33 changed files with 11157 additions and 2163 deletions
+505
View File
@@ -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
})
}