feat(kiosk): integrate libmpv, full Yatse JSON-RPC & EventServer, and YouTube playback
This commit is contained in:
+303
-10
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user