feat(kiosk): integrate libmpv, full Yatse JSON-RPC & EventServer, and YouTube playback

This commit is contained in:
bart
2026-11-04 23:26:08 +01:00
parent d77cdfedab
commit 4f2cdbd817
12 changed files with 963 additions and 736 deletions
+303 -10
View File
@@ -88,7 +88,89 @@ async fn handle_root() -> impl IntoResponse {
"Jellyfin HTPC Kiosk (Rust) - Kodi JSON-RPC Server for Yatse"
}
async fn handle_jsonrpc_get() -> impl IntoResponse {
fn percent_decode(input: &str) -> String {
let mut bytes = Vec::new();
let mut chars = input.bytes();
while let Some(b) = chars.next() {
if b == b'%' {
let h1 = chars.next().unwrap_or(b'0');
let h2 = chars.next().unwrap_or(b'0');
let hex = [h1, h2];
if let Ok(s) = std::str::from_utf8(&hex) {
if let Ok(val) = u8::from_str_radix(s, 16) {
bytes.push(val);
continue;
}
}
bytes.push(b'%');
bytes.push(h1);
bytes.push(h2);
} else if b == b'+' {
bytes.push(b' ');
} else {
bytes.push(b);
}
}
String::from_utf8_lossy(&bytes).to_string()
}
pub fn normalize_playback_url(file: &str) -> String {
let trimmed = file.trim();
if trimmed.starts_with("plugin://plugin.video.youtube") {
if let Some(query) = trimmed.split('?').nth(1) {
for param in query.split('&') {
let mut kv = param.splitn(2, '=');
if let (Some(k), Some(v)) = (kv.next(), kv.next()) {
let k_lower = k.to_lowercase();
if k_lower == "video_id" || k_lower == "videoid" {
let vid = percent_decode(v);
return format!("https://www.youtube.com/watch?v={}", vid);
}
}
}
}
} else if trimmed.starts_with("plugin://plugin.video.sendtokodi") {
if let Some(query) = trimmed.split('?').nth(1) {
let decoded = percent_decode(query);
if decoded.starts_with("http://") || decoded.starts_with("https://") {
return decoded;
}
if let Some(url_part) = decoded.split("url=").nth(1) {
return percent_decode(url_part);
}
}
}
trimmed.to_string()
}
async fn handle_jsonrpc_get(
State(state): State<YatseServerState>,
uri: axum::http::Uri,
) -> impl IntoResponse {
if let Some(query) = uri.query() {
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
if let (Some(k), Some(v)) = (parts.next(), parts.next()) {
if k == "request" {
let decoded = percent_decode(v);
if let Ok(val) = serde_json::from_str::<Value>(&decoded) {
if let Some(arr) = val.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);
}
}
return Json(Value::Array(results));
} else if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(val) {
let res = dispatch_rpc(&state, parsed).await;
return Json(res);
}
}
}
}
}
}
Json(json!({
"jsonrpc": "2.0",
"result": {
@@ -98,6 +180,76 @@ async fn handle_jsonrpc_get() -> impl IntoResponse {
}))
}
pub async fn start_tcp_jsonrpc_server(port: u16, state: YatseServerState) {
let addr = format!("0.0.0.0:{}", port);
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => {
tracing::info!("Yatse Kodi TCP JSON-RPC socket server listening on {}", addr);
l
}
Err(e) => {
tracing::warn!("Failed to bind TCP JSON-RPC server on {}: {}", addr, e);
return;
}
};
tokio::spawn(async move {
loop {
let (socket, peer) = match listener.accept().await {
Ok(res) => res,
Err(e) => {
tracing::warn!("Error accepting TCP JSON-RPC connection: {}", e);
continue;
}
};
tracing::info!("Yatse TCP JSON-RPC connection accepted from {}", peer);
let state_clone = state.clone();
tokio::spawn(async move {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let (reader, mut writer) = socket.into_split();
let mut buf_reader = BufReader::new(reader);
let mut line = String::new();
loop {
line.clear();
match buf_reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(val) = serde_json::from_str::<Value>(trimmed) {
if let Some(arr) = val.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_clone, parsed).await);
}
}
let out = serde_json::to_string(&Value::Array(results)).unwrap_or_default() + "\n";
let _ = writer.write_all(out.as_bytes()).await;
} else if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(val) {
let res = dispatch_rpc(&state_clone, parsed).await;
let out = serde_json::to_string(&res).unwrap_or_default() + "\n";
let _ = writer.write_all(out.as_bytes()).await;
}
}
}
Err(e) => {
tracing::debug!("TCP client {} read error: {}", peer, e);
break;
}
}
}
tracing::info!("Yatse TCP connection closed from {}", peer);
});
}
});
}
async fn handle_jsonrpc(
State(state): State<YatseServerState>,
Json(req): Json<Value>,
@@ -181,18 +333,15 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
let new_vol = if let Some(v_str) = vol_param.and_then(|v| v.as_str()) {
if v_str == "increment" {
state.player.volume_up().await;
let _ = state.nav_tx.send(RemoteNavAction::VolumeUp);
state.player.status.lock().await.volume as i64
} else if v_str == "decrement" {
state.player.volume_down().await;
let _ = state.nav_tx.send(RemoteNavAction::VolumeDown);
state.player.status.lock().await.volume as i64
} else {
state.player.status.lock().await.volume as i64
}
} else if let Some(v) = vol_param.and_then(|v| v.as_f64()) {
state.player.set_volume(v).await;
let _ = state.nav_tx.send(RemoteNavAction::VolumeUp);
v.clamp(0.0, 100.0) as i64
} else {
state.player.status.lock().await.volume as i64
@@ -351,7 +500,22 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Player.SetAudioStream" => {
state.player.cycle_audio().await;
if let Some(ref params) = req.params {
let stream_param = params.get("stream").or_else(|| params.as_array().and_then(|a| a.get(1)));
if let Some(s) = stream_param {
if let Some(idx) = s.as_i64() {
state.player.set_audio_track((idx + 1) as i32).await;
} else if s.as_str() == Some("next") {
state.player.cycle_audio().await;
} else if s.as_str() == Some("previous") {
state.player.cycle_audio().await;
}
} else {
state.player.cycle_audio().await;
}
} else {
state.player.cycle_audio().await;
}
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Player.SetSpeed" => {
@@ -385,10 +549,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
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 norm_url = normalize_playback_url(file);
tracing::info!("Player.Open requested file: '{}' -> normalized: '{}'", file, norm_url);
let p = state.player.clone();
tokio::spawn(async move {
let _ = p.play(&file_str, "Media from Yatse").await;
let _ = p.play(&norm_url, "Media from Yatse").await;
});
}
}
@@ -460,19 +625,16 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
}
"title" | "menu" | "11" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); }
"volumeup" | "88" => {
let _ = state.nav_tx.send(RemoteNavAction::VolumeUp);
let p = state.player.clone();
tokio::spawn(async move { p.volume_up().await; });
}
"volumedown" | "89" => {
let _ = state.nav_tx.send(RemoteNavAction::VolumeDown);
let p = state.player.clone();
tokio::spawn(async move { p.volume_down().await; });
}
"playpause" | "pause" | "play" | "15" => { state.player.play_pause().await; }
"stop" | "14" => {
let _ = state.nav_tx.send(RemoteNavAction::Stop);
state.player.stop().await;
}
"mute" | "togglemute" | "13" => {
let p = state.player.clone();
@@ -648,6 +810,112 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"GUI.ShowNotification" => {
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Addons.GetAddonDetails" => {
let addon_id = req.params.as_ref()
.and_then(|p| p.get("addonid").or_else(|| p.as_array().and_then(|a| a.first())))
.and_then(|s| s.as_str())
.unwrap_or("plugin.video.youtube");
let (name, a_type, version) = match addon_id {
"plugin.video.youtube" => ("YouTube", "xbmc.python.pluginsource", "7.0.0"),
"script.yatse.kodi" => ("Yatse Kodi Helper Script", "xbmc.service", "2.0.0"),
"plugin.video.sendtokodi" | "plugin.video.sendtokodi.python3" => ("SendToKodi", "xbmc.python.pluginsource", "1.0.0"),
other => (other, "xbmc.python.pluginsource", "1.0.0"),
};
json!({
"jsonrpc": "2.0",
"result": {
"addon": {
"addonid": addon_id,
"name": name,
"type": a_type,
"version": version,
"summary": name,
"description": name,
"author": "Yatse / Community",
"enabled": true
}
},
"id": id
})
}
"Addons.GetAddons" => {
json!({
"jsonrpc": "2.0",
"result": {
"addons": [
{
"addonid": "plugin.video.youtube",
"name": "YouTube",
"type": "xbmc.python.pluginsource",
"version": "7.0.0",
"enabled": true
},
{
"addonid": "script.yatse.kodi",
"name": "Yatse Kodi Helper Script",
"type": "xbmc.service",
"version": "2.0.0",
"enabled": true
},
{
"addonid": "plugin.video.sendtokodi",
"name": "SendToKodi",
"type": "xbmc.python.pluginsource",
"version": "1.0.0",
"enabled": true
}
],
"limits": {
"start": 0,
"end": 3,
"total": 3
}
},
"id": id
})
}
"Addons.ExecuteAddon" => {
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Files.GetSources" => {
let media = req.params.as_ref()
.and_then(|p| p.get("media").or_else(|| p.as_array().and_then(|a| a.first())))
.and_then(|s| s.as_str())
.unwrap_or("video");
let sources = if media == "video" {
vec![
json!({
"file": "plugin://plugin.video.youtube/",
"label": "YouTube",
"media": "video"
})
]
} else {
vec![]
};
json!({
"jsonrpc": "2.0",
"result": {
"sources": sources,
"limits": {"start": 0, "end": 1, "total": 1}
},
"id": id
})
}
"Files.GetDirectory" => {
json!({
"jsonrpc": "2.0",
"result": {
"files": [],
"limits": {"start": 0, "end": 0, "total": 0}
},
"id": id
})
}
_ => {
json!({
"jsonrpc": "2.0",
@@ -671,3 +939,28 @@ fn seconds_to_kodi_time(seconds: f64) -> Value {
"milliseconds": millis
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_playback_url() {
assert_eq!(
normalize_playback_url("plugin://plugin.video.youtube/play/?video_id=6G45Qk0HQNY"),
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
);
assert_eq!(
normalize_playback_url("plugin://plugin.video.youtube/?action=play_video&videoid=6G45Qk0HQNY"),
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
);
assert_eq!(
normalize_playback_url("plugin://plugin.video.sendtokodi/?https%3A%2F%2Fyoutube.com%2Fwatch%3Fv%3D6G45Qk0HQNY"),
"https://youtube.com/watch?v=6G45Qk0HQNY"
);
assert_eq!(
normalize_playback_url("https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"),
"https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"
);
}
}