use axum::{ extract::State, response::{IntoResponse, Json}, routing::{get, post}, Router, }; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::RwLock; use crate::jellyfin::{JellyfinClient, MediaItem}; use crate::player::{PlaybackState, PlayerController}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RemoteNavAction { Up, Down, Left, Right, Select, Back, ToggleOSD, CycleSubtitle, CycleAudio, ToggleMute, TogglePlaybackView, ActivateSection(usize), VolumeUp, VolumeDown, PlayPause, Stop, SendText(String), } #[derive(Clone)] pub struct RemoteNavSender { tx: UnboundedSender, ctx: Arc>>, } impl RemoteNavSender { pub fn new(tx: UnboundedSender, ctx: Arc>>) -> Self { Self { tx, ctx } } pub fn send(&self, action: RemoteNavAction) -> Result<(), tokio::sync::mpsc::error::SendError> { let res = self.tx.send(action); if let Ok(guard) = self.ctx.lock() { if let Some(ref c) = *guard { c.request_repaint(); } } res } } impl From> for RemoteNavSender { fn from(tx: UnboundedSender) -> Self { Self { tx, ctx: Arc::new(std::sync::Mutex::new(None)), } } } #[derive(Default, Clone)] pub struct IdMap { str_to_id: Arc>>, id_to_str: Arc>>, next_id: Arc>, } impl IdMap { pub async fn get_or_create(&self, str_id: &str) -> i64 { { let read = self.str_to_id.read().await; if let Some(&id) = read.get(str_id) { return id; } } let mut write_str = self.str_to_id.write().await; let mut write_id = self.id_to_str.write().await; let mut next = self.next_id.write().await; if let Some(&id) = write_str.get(str_id) { return id; } if *next <= 0 { *next = 1; } let id = *next; *next += 1; write_str.insert(str_id.to_string(), id); write_id.insert(id, str_id.to_string()); id } pub async fn get_str(&self, id: i64) -> Option { let read = self.id_to_str.read().await; read.get(&id).cloned() } } #[derive(Clone)] pub struct YatseServerState { pub player: Arc, pub nav_tx: RemoteNavSender, pub client: JellyfinClient, pub id_map: IdMap, pub episodes_cache: Arc>>>, } impl YatseServerState { pub fn new(player: Arc, nav_tx: RemoteNavSender, client: JellyfinClient) -> Self { Self { player, nav_tx, client, id_map: IdMap::default(), episodes_cache: Arc::new(RwLock::new(HashMap::new())), } } } fn parse_limits(params: Option<&Value>, total: usize) -> (usize, usize) { if let Some(p) = params { let limits_obj = p.get("limits") .or_else(|| p.as_array().and_then(|a| a.iter().find_map(|x| x.get("limits")))); if let Some(limits) = limits_obj { let start = limits.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize; let end_val = limits.get("end").and_then(|v| v.as_u64()).unwrap_or(0) as usize; let start = start.min(total); let end = if end_val == 0 || end_val <= start { total } else { end_val.min(total) }; return (start, end); } } (0, total) } fn parse_limits_raw(params: Option<&Value>, default_limit: usize) -> (usize, usize) { if let Some(p) = params { let limits_obj = p.get("limits") .or_else(|| p.as_array().and_then(|a| a.iter().find_map(|x| x.get("limits")))); if let Some(limits) = limits_obj { let start = limits.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize; let end_val = limits.get("end").and_then(|v| v.as_u64()).unwrap_or(0) as usize; let limit = if end_val > start { end_val - start } else { default_limit }; return (start, limit); } } (0, default_limit) } #[derive(Debug, Deserialize)] struct JsonRpcRequest { #[allow(dead_code)] jsonrpc: Option, method: String, params: Option, id: Option, } 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) } async fn handle_root() -> impl IntoResponse { "Jellyfin HTPC Kiosk (Rust) - Kodi JSON-RPC Server for Yatse" } 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 { 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, 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, 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() { 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 normalize_playback_url(&decoded); } if let Some(url_part) = decoded.split("url=").nth(1) { return normalize_playback_url(&percent_decode(url_part)); } } } else if trimmed.contains("youtube.com/watch") || trimmed.contains("youtu.be/") || trimmed.contains("youtube.com/shorts/") { if let Some(pos) = trimmed.find("v=") { let rest = &trimmed[pos + 2..]; let vid = rest.split('&').next().unwrap_or(rest); if !vid.is_empty() { return format!("https://www.youtube.com/watch?v={}", vid); } } else if let Some(pos) = trimmed.find("youtu.be/") { let rest = &trimmed[pos + 9..]; let vid = rest.split('?').next().unwrap_or(rest); if !vid.is_empty() { return format!("https://www.youtube.com/watch?v={}", vid); } } else if let Some(pos) = trimmed.find("youtube.com/shorts/") { let rest = &trimmed[pos + 19..]; let vid = rest.split('?').next().unwrap_or(rest); if !vid.is_empty() { return format!("https://www.youtube.com/watch?v={}", vid); } } } trimmed.to_string() } pub fn extract_addon_share_url(params: &Value) -> Option { // 1. Direct fields for key in ["data", "url", "file"] { if let Some(v) = params.get(key).and_then(|v| v.as_str()) { return Some(v.to_string()); } } // 2. Nested under "params" if let Some(inner) = params.get("params") { if let Some(obj) = inner.as_object() { for key in ["data", "url", "file"] { if let Some(v) = obj.get(key).and_then(|v| v.as_str()) { return Some(v.to_string()); } } } else if let Some(s) = inner.as_str() { let q = s.trim_start_matches('?'); for pair in q.split('&') { let mut kv = pair.splitn(2, '='); if let (Some(k), Some(v)) = (kv.next(), kv.next()) { if k == "data" || k == "url" || k == "file" { return Some(v.to_string()); } } } if s.contains("http://") || s.contains("https://") || s.contains("plugin://") { return Some(s.to_string()); } } else if let Some(arr) = inner.as_array() { for item in arr { if let Some(s) = item.as_str() { let q = s.trim_start_matches('?'); for pair in q.split('&') { let mut kv = pair.splitn(2, '='); if let (Some(k), Some(v)) = (kv.next(), kv.next()) { if k == "data" || k == "url" || k == "file" { return Some(v.to_string()); } } } if s.contains("http://") || s.contains("https://") || s.contains("plugin://") { return Some(s.to_string()); } } } } } None } async fn handle_jsonrpc_get( State(state): State, 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::(&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::(item.clone()) { results.push(dispatch_rpc(&state, parsed).await); } } return Json(Value::Array(results)); } else if let Ok(parsed) = serde_json::from_value::(val) { let res = dispatch_rpc(&state, parsed).await; return Json(res); } } } } } } Json(json!({ "jsonrpc": "2.0", "result": { "version": {"major": 19, "minor": 0, "patch": 0} }, "id": 1 })) } 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::(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::(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::(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, Json(req): Json, ) -> 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::(item.clone()) { results.push(dispatch_rpc(&state, parsed).await); } } Json(Value::Array(results)) } else if let Ok(parsed) = serde_json::from_value::(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()))); 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; state.player.status.lock().await.volume as i64 } else if v_str == "decrement" { state.player.volume_down().await; 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; v.clamp(0.0, 100.0) as i64 } else { 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 { let pid = if st.media_type == "audio" { 0 } else { 1 }; let ptype = if st.media_type == "audio" { "audio" } else { "video" }; json!({ "jsonrpc": "2.0", "result": [{"playerid": pid, "playertype": "internal", "type": ptype}], "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 }; let ptype = if st.media_type == "audio" { "audio" } else { "video" }; json!({ "jsonrpc": "2.0", "result": { "canseek": true, "canchangespeed": true, "canmove": false, "canzoom": false, "canrotate": false, "speed": speed, "time": time, "totaltime": totaltime, "percentage": if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { 0.0 }, "type": ptype, "live": false, "subtitleenabled": true, "subtitles": [ {"index": 0, "name": "Ondertitel 1", "language": ""}, {"index": 1, "name": "Ondertitel 2", "language": ""} ], "currentsubtitle": {"index": 0, "name": "Ondertitel", "language": ""}, "audiostreams": [ {"index": 0, "name": "Audiostroom 1", "language": ""}, {"index": 1, "name": "Audiostroom 2", "language": ""} ], "currentaudiostream": {"index": 0, "name": "Audiostroom", "language": ""} }, "id": id }) } "Player.GetItem" => { let st = state.player.status.lock().await; let title = if !st.stream_title.is_empty() { &st.stream_title } else if !st.title.is_empty() { &st.title } else { "Media" }; let itype = if st.media_type == "audio" { "song" } else { "movie" }; json!({ "jsonrpc": "2.0", "result": { "item": { "label": title, "title": title, "artist": if !st.artist.is_empty() { vec![st.artist.clone()] } else { Vec::new() }, "type": itype } }, "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; let _ = state.nav_tx.send(RemoteNavAction::Stop); json!({ "jsonrpc": "2.0", "result": "OK", "id": id }) } "Player.Seek" => { let mut result_pct = 0.0; if let Some(ref params) = req.params { let val = params.get("value").or_else(|| params.as_array().and_then(|a| a.get(1))); if let Some(v) = val { if let Some(pct) = v.get("percentage").and_then(|p| p.as_f64()) { state.player.seek_percent(pct).await; result_pct = pct; } else if let Some(time_obj) = v.get("time") { let h = time_obj.get("hours").and_then(|x| x.as_f64()).unwrap_or(0.0); let m = time_obj.get("minutes").and_then(|x| x.as_f64()).unwrap_or(0.0); let s = time_obj.get("seconds").and_then(|x| x.as_f64()).unwrap_or(0.0); let total_secs = h * 3600.0 + m * 60.0 + s; state.player.seek_time(total_secs).await; } else if let Some(step) = v.as_str() { match step.to_lowercase().as_str() { "smallforward" => { state.player.seek(10.0).await; } "smallbackward" => { state.player.seek(-10.0).await; } "bigforward" => { state.player.seek(60.0).await; } "bigbackward" => { state.player.seek(-60.0).await; } _ => {} } } else if let Some(n) = v.as_f64() { if n >= 0.0 && n <= 100.0 { state.player.seek_percent(n).await; result_pct = n; } else { state.player.seek(n).await; } } } } 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 pct = if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { result_pct }; json!({ "jsonrpc": "2.0", "result": { "percentage": pct, "time": time, "totaltime": totaltime }, "id": id }) } "Player.SetSubtitle" => { state.player.cycle_subtitle().await; let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); json!({"jsonrpc": "2.0", "result": "OK", "id": id}) } "Player.SetAudioStream" => { 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" => { if let Some(ref params) = req.params { let speed_val = params.get("speed").or_else(|| params.as_array().and_then(|a| a.get(1))); if let Some(s) = speed_val.and_then(|v| v.as_i64()) { if s == 0 { let st = state.player.status.lock().await; if st.state == PlaybackState::Playing { drop(st); state.player.play_pause().await; } } else if s == 1 { let st = state.player.status.lock().await; if st.state == PlaybackState::Paused { drop(st); state.player.play_pause().await; } } } else if let Some(s_str) = speed_val.and_then(|v| v.as_str()) { if s_str == "increment" || s_str == "decrement" { 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.Open" => { if let Some(ref params) = req.params { let item_opt = params.get("item") .or_else(|| params.as_array().and_then(|a| a.iter().find_map(|x| x.get("item")))) .or_else(|| params.as_array().and_then(|a| a.first())); if let Some(item) = item_opt { let movieid_opt = item.get("movieid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))); let episodeid_opt = item.get("episodeid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))); let songid_opt = item.get("songid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))); let file_opt = item.get("file").and_then(|f| f.as_str()).map(|s| s.to_string()); let p = state.player.clone(); let client = state.client.clone(); let id_map = state.id_map.clone(); let episodes_cache = state.episodes_cache.clone(); tokio::spawn(async move { let _ = client.ensure_authenticated().await; let base_url = client.config.server_url.trim_end_matches('/').to_string(); let token = client.get_token().await.unwrap_or_default(); if let Some(mid) = movieid_opt { if let Some(jellyfin_id) = id_map.get_str(mid).await { let stream_url = format!("{}/Videos/{}/stream?static=true&api_key={}", base_url, jellyfin_id, token); let lib = client.get_library().await; let title = lib.all_movies.iter().find(|m| m.id == jellyfin_id).map(|m| m.name.clone()).unwrap_or_else(|| "Film".into()); tracing::info!("Player.Open movieid={} (jellyfin={}) -> {}", mid, jellyfin_id, stream_url); let _ = p.play(&stream_url, &title).await; return; } } else if let Some(eid) = episodeid_opt { if let Some(jellyfin_id) = id_map.get_str(eid).await { let stream_url = format!("{}/Videos/{}/stream?static=true&api_key={}", base_url, jellyfin_id, token); let ep_title = { let cache = episodes_cache.read().await; cache.values().flatten().find(|e| e.id == jellyfin_id).map(|e| e.name.clone()).unwrap_or_else(|| "Aflevering".into()) }; tracing::info!("Player.Open episodeid={} (jellyfin={}) -> {}", eid, jellyfin_id, stream_url); let _ = p.play(&stream_url, &ep_title).await; return; } } else if let Some(sid) = songid_opt { if let Some(jellyfin_id) = id_map.get_str(sid).await { let stream_url = format!("{}/Audio/{}/stream?static=true&api_key={}", base_url, jellyfin_id, token); tracing::info!("Player.Open songid={} (jellyfin={}) -> {}", sid, jellyfin_id, stream_url); let _ = p.play(&stream_url, "Muziek via Yatse").await; return; } } else if let Some(file) = file_opt { if file.starts_with("jellyfin://movie/") || file.starts_with("jellyfin://episode/") || file.starts_with("jellyfin://video/") { let item_id = file.rsplit('/').next().unwrap_or(""); let stream_url = format!("{}/Videos/{}/stream?static=true&api_key={}", base_url, item_id, token); tracing::info!("Player.Open video file='{}' -> {}", file, stream_url); let _ = p.play(&stream_url, "Media from Yatse").await; return; } if file.starts_with("jellyfin://audio/") || file.starts_with("jellyfin://song/") { let item_id = file.rsplit('/').next().unwrap_or(""); let stream_url = format!("{}/Audio/{}/stream?static=true&api_key={}", base_url, item_id, token); tracing::info!("Player.Open audio file='{}' -> {}", file, stream_url); let _ = p.play(&stream_url, "Muziek via Yatse").await; return; } let norm_url = normalize_playback_url(&file); tracing::info!("Player.Open file: '{}' -> normalized: '{}'", file, norm_url); let _ = p.play(&norm_url, "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.SendText" => { let mut text_opt = None; if let Some(ref params) = req.params { if let Some(t) = params.get("text").and_then(|x| x.as_str()) { text_opt = Some(t.to_string()); } else if let Some(t) = params.as_array().and_then(|a| a.first()).and_then(|x| x.as_str()) { text_opt = Some(t.to_string()); } } if let Some(text) = text_opt { let _ = state.nav_tx.send(RemoteNavAction::SendText(text)); } json!({"jsonrpc": "2.0", "result": "OK", "id": id}) } "Input.ExecuteAction" => { let raw_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(""); let action = if raw_action.to_lowercase().starts_with("action(") && raw_action.ends_with(')') { raw_action[7..raw_action.len() - 1].trim().to_lowercase() } else { raw_action.to_lowercase() }; match action.as_str() { "up" | "cursorup" | "3" => { let _ = state.nav_tx.send(RemoteNavAction::Up); } "down" | "cursordown" | "4" => { let _ = state.nav_tx.send(RemoteNavAction::Down); } "left" | "cursorleft" | "1" | "stepback" | "smallstepback" => { let _ = state.nav_tx.send(RemoteNavAction::Left); } "right" | "cursorright" | "2" | "stepforward" | "smallstepforward" => { let _ = state.nav_tx.send(RemoteNavAction::Right); } "bigstepforward" => { let p = state.player.clone(); tokio::spawn(async move { p.seek(60.0).await; }); } "bigstepback" => { let p = state.player.clone(); tokio::spawn(async move { p.seek(-60.0).await; }); } "select" | "enter" | "7" => { let _ = state.nav_tx.send(RemoteNavAction::Select); } "back" | "previousmenu" | "parentdir" | "close" | "9" | "10" | "escape" => { let _ = state.nav_tx.send(RemoteNavAction::Back); } "togglefullscreen" | "12" => { let _ = state.nav_tx.send(RemoteNavAction::ToggleOSD); } "osd" | "nextsubtitle" | "114" | "122" => { let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); } "audionextlanguage" | "nextaudio" => { let p = state.player.clone(); tokio::spawn(async move { p.cycle_audio().await; }); } "title" | "menu" | "11" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); } "volumeup" | "88" => { let p = state.player.clone(); tokio::spawn(async move { p.volume_up().await; }); } "volumedown" | "89" => { 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); } "mute" | "togglemute" | "13" => { let p = state.player.clone(); tokio::spawn(async move { p.toggle_mute().await; }); } _ => {} } json!({"jsonrpc": "2.0", "result": "OK", "id": id}) } "GUI.ActivateWindow" => { if let Some(params) = req.params { let p_str = params.to_string().to_lowercase(); if p_str.contains("tvshow") || p_str.contains("series") { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(3)); } else if p_str.contains("movietitles") || p_str.contains("movie") || p_str.contains("video") { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(2)); } else if p_str.contains("music") || p_str.contains("song") || p_str.contains("artist") || p_str.contains("album") { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(4)); } else if p_str.contains("picture") || p_str.contains("photo") { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(5)); } else if p_str.contains("livetv") || p_str.contains("channel") || p_str.contains("tv") { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(1)); } else if p_str.contains("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}) } "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" => { if let Some(ref params) = req.params { if let Some(raw_url) = extract_addon_share_url(params) { let decoded_url = percent_decode(&raw_url); let norm_url = normalize_playback_url(&decoded_url); tracing::info!("Addons.ExecuteAddon share requested: raw='{}' -> norm='{}'", raw_url, norm_url); let p = state.player.clone(); tokio::spawn(async move { let _ = p.play(&norm_url, "YouTube via Yatse").await; }); } else { tracing::warn!("Addons.ExecuteAddon received without resolvable URL: {:?}", params); } } json!({"jsonrpc": "2.0", "result": "OK", "id": id}) } "Playlist.Add" | "Playlist.Insert" => { if let Some(ref params) = req.params { if let Some(item) = params.get("item") { if let Some(file) = item.get("file").and_then(|f| f.as_str()) { let norm_url = normalize_playback_url(file); tracing::info!("Playlist.Add requested file: '{}' -> '{}'", file, norm_url); let p = state.player.clone(); tokio::spawn(async move { let _ = p.play(&norm_url, "Media from Yatse").await; }); } } } json!({"jsonrpc": "2.0", "result": "OK", "id": id}) } "Playlist.Clear" => { json!({"jsonrpc": "2.0", "result": "OK", "id": id}) } "Playlist.GetItems" => { json!({ "jsonrpc": "2.0", "result": { "items": [], "limits": {"start": 0, "end": 0, "total": 0} }, "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": "jellyfin://movies/", "label": "Jellyfin Films", "media": "video" }), json!({ "file": "jellyfin://shows/", "label": "Jellyfin Series", "media": "video" }), json!({ "file": "plugin://plugin.video.youtube/", "label": "YouTube", "media": "video" }) ] } else { vec![] }; json!({ "jsonrpc": "2.0", "result": { "sources": sources, "limits": {"start": 0, "end": sources.len(), "total": sources.len()} }, "id": id }) } "Files.GetDirectory" => { json!({ "jsonrpc": "2.0", "result": { "files": [], "limits": {"start": 0, "end": 0, "total": 0} }, "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; let mut movies = Vec::new(); for m in &lib.all_movies { let mid = state.id_map.get_or_create(&m.id).await; let runtime = m.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0); let genres: Vec<&str> = m.genre.as_deref().unwrap_or("").split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); let playcount = if m.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 }; movies.push(json!({ "movieid": mid, "label": m.name, "title": m.name, "originaltitle": m.name, "year": m.year.unwrap_or(0), "rating": m.rating.unwrap_or(0.0), "plot": m.overview.clone().unwrap_or_default(), "runtime": runtime, "playcount": playcount, "genre": genres, "file": format!("jellyfin://movie/{}", m.id), "thumbnail": to_kodi_art(&m.thumb_url(base_url)), "fanart": to_kodi_art(&m.backdrop_url(base_url)), "art": { "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, "total": runtime as f64 } })); } let total = movies.len(); let (start, end) = parse_limits(req.params.as_ref(), total); let paged = movies[start..end].to_vec(); json!({ "jsonrpc": "2.0", "result": { "limits": {"start": start, "end": end, "total": total}, "movies": paged }, "id": id }) } "VideoLibrary.GetMovieDetails" => { let mid_opt = req.params.as_ref().and_then(|p| { p.get("movieid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) .or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))) }); let mut details = json!({}); if let Some(mid) = mid_opt { if let Some(jellyfin_id) = state.id_map.get_str(mid).await { let lib = state.client.get_library().await; if let Some(m) = lib.all_movies.iter().find(|x| x.id == jellyfin_id) { let base_url = &state.client.config.server_url; let runtime = m.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0); let genres: Vec<&str> = m.genre.as_deref().unwrap_or("").split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); let playcount = if m.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 }; details = json!({ "movieid": mid, "label": m.name, "title": m.name, "originaltitle": m.name, "year": m.year.unwrap_or(0), "rating": m.rating.unwrap_or(0.0), "plot": m.overview.clone().unwrap_or_default(), "runtime": runtime, "playcount": playcount, "genre": genres, "file": format!("jellyfin://movie/{}", m.id), "thumbnail": to_kodi_art(&m.thumb_url(base_url)), "fanart": to_kodi_art(&m.backdrop_url(base_url)), "art": { "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, "total": runtime as f64 } }); } } } json!({ "jsonrpc": "2.0", "result": { "moviedetails": details }, "id": id }) } "VideoLibrary.GetRecentlyAddedMovies" => { let lib = state.client.get_library().await; let base_url = &state.client.config.server_url; let mut movies = Vec::new(); for m in &lib.latest_movies { let mid = state.id_map.get_or_create(&m.id).await; let runtime = m.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0); let genres: Vec<&str> = m.genre.as_deref().unwrap_or("").split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); let playcount = if m.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 }; movies.push(json!({ "movieid": mid, "label": m.name, "title": m.name, "originaltitle": m.name, "year": m.year.unwrap_or(0), "rating": m.rating.unwrap_or(0.0), "plot": m.overview.clone().unwrap_or_default(), "runtime": runtime, "playcount": playcount, "genre": genres, "file": format!("jellyfin://movie/{}", m.id), "thumbnail": to_kodi_art(&m.thumb_url(base_url)), "fanart": to_kodi_art(&m.backdrop_url(base_url)), "art": { "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)), } })); } let total = movies.len(); let (start, end) = parse_limits(req.params.as_ref(), total); let paged = movies[start..end].to_vec(); json!({ "jsonrpc": "2.0", "result": { "limits": {"start": start, "end": end, "total": total}, "movies": paged }, "id": id }) } "VideoLibrary.GetTVShows" => { let lib = state.client.get_library().await; let base_url = &state.client.config.server_url; let mut shows = Vec::new(); for s in &lib.all_shows { let tid = state.id_map.get_or_create(&s.id).await; let genres: Vec<&str> = s.genre.as_deref().unwrap_or("").split(',').map(|g| g.trim()).filter(|g| !g.is_empty()).collect(); shows.push(json!({ "tvshowid": tid, "label": s.name, "title": s.name, "originaltitle": s.name, "year": s.year.unwrap_or(0), "rating": s.rating.unwrap_or(0.0), "plot": s.overview.clone().unwrap_or_default(), "genre": genres, "episode": s.recursive_item_count.unwrap_or(1), "season": s.child_count.unwrap_or(1), "watchedepisodes": 0, "file": format!("jellyfin://show/{}", s.id), "thumbnail": to_kodi_art(&s.thumb_url(base_url)), "fanart": to_kodi_art(&s.backdrop_url(base_url)), "art": { "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)), } })); } let total = shows.len(); let (start, end) = parse_limits(req.params.as_ref(), total); let paged = shows[start..end].to_vec(); json!({ "jsonrpc": "2.0", "result": { "limits": {"start": start, "end": end, "total": total}, "tvshows": paged }, "id": id }) } "VideoLibrary.GetTVShowDetails" => { let tid_opt = req.params.as_ref().and_then(|p| { p.get("tvshowid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) .or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))) }); let mut details = json!({}); if let Some(tid) = tid_opt { if let Some(jellyfin_id) = state.id_map.get_str(tid).await { let lib = state.client.get_library().await; if let Some(s) = lib.all_shows.iter().find(|x| x.id == jellyfin_id) { let base_url = &state.client.config.server_url; let genres: Vec<&str> = s.genre.as_deref().unwrap_or("").split(',').map(|g| g.trim()).filter(|g| !g.is_empty()).collect(); details = json!({ "tvshowid": tid, "label": s.name, "title": s.name, "originaltitle": s.name, "year": s.year.unwrap_or(0), "rating": s.rating.unwrap_or(0.0), "plot": s.overview.clone().unwrap_or_default(), "genre": genres, "episode": s.recursive_item_count.unwrap_or(1), "season": s.child_count.unwrap_or(1), "watchedepisodes": 0, "file": format!("jellyfin://show/{}", s.id), "thumbnail": to_kodi_art(&s.thumb_url(base_url)), "fanart": to_kodi_art(&s.backdrop_url(base_url)), "art": { "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)), } }); } } } json!({ "jsonrpc": "2.0", "result": { "tvshowdetails": details }, "id": id }) } "VideoLibrary.GetSeasons" => { let tid_opt = req.params.as_ref().and_then(|p| { p.get("tvshowid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) .or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))) }); let mut seasons = Vec::new(); if let Some(tid) = tid_opt { if let Some(jellyfin_id) = state.id_map.get_str(tid).await { let episodes = { let cache = state.episodes_cache.read().await; cache.get(&jellyfin_id).cloned() }; let eps = match episodes { Some(e) => e, None => { let fetched = state.client.fetch_show_episodes(&jellyfin_id).await; state.episodes_cache.write().await.insert(jellyfin_id.clone(), fetched.clone()); fetched } }; let mut season_map: std::collections::BTreeMap = std::collections::BTreeMap::new(); for ep in &eps { let s_num = ep.parent_index_number.unwrap_or(1); *season_map.entry(s_num).or_insert(0) += 1; } let base_url = &state.client.config.server_url; let lib = state.client.get_library().await; let show = lib.all_shows.iter().find(|x| x.id == jellyfin_id); let poster = show.map(|s| s.poster_url(base_url)).unwrap_or_default(); let backdrop = show.map(|s| s.backdrop_url(base_url)).unwrap_or_default(); for (s_num, ep_count) in season_map { let sid = state.id_map.get_or_create(&format!("{}:season:{}", jellyfin_id, s_num)).await; seasons.push(json!({ "seasonid": sid, "tvshowid": tid, "season": s_num, "label": format!("Seizoen {}", s_num), "episode": ep_count, "watchedepisodes": 0, "thumbnail": to_kodi_art(&poster), "fanart": to_kodi_art(&backdrop), "art": { "poster": to_kodi_art(&poster), "fanart": to_kodi_art(&backdrop), "thumb": to_kodi_art(&poster) } })); } } } let total = seasons.len(); let (start, end) = parse_limits(req.params.as_ref(), total); let paged = seasons[start..end].to_vec(); json!({ "jsonrpc": "2.0", "result": { "limits": {"start": start, "end": end, "total": total}, "seasons": paged }, "id": id }) } "VideoLibrary.GetSeasonDetails" => { json!({ "jsonrpc": "2.0", "result": { "seasondetails": {} }, "id": id }) } "VideoLibrary.GetEpisodes" => { let tid_opt = req.params.as_ref().and_then(|p| { p.get("tvshowid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) .or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))) }); let season_filter = req.params.as_ref().and_then(|p| { p.get("season").and_then(|v| v.as_u64().map(|n| n as u32)) }); let base_url = &state.client.config.server_url; if let Some(tid) = tid_opt { let mut episodes_out = Vec::new(); if let Some(jellyfin_id) = state.id_map.get_str(tid).await { let episodes = { let cache = state.episodes_cache.read().await; cache.get(&jellyfin_id).cloned() }; let eps = match episodes { Some(e) => e, None => { let fetched = state.client.fetch_show_episodes(&jellyfin_id).await; state.episodes_cache.write().await.insert(jellyfin_id.clone(), fetched.clone()); fetched } }; for ep in &eps { let s_num = ep.parent_index_number.unwrap_or(1); if let Some(sf) = season_filter { if s_num != sf { continue; } } let ep_num = ep.index_number.unwrap_or(1); let epid = state.id_map.get_or_create(&ep.id).await; let runtime = ep.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0); let playcount = if ep.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 }; episodes_out.push(json!({ "episodeid": epid, "tvshowid": tid, "season": s_num, "episode": ep_num, "label": format!("{}x{:02}. {}", s_num, ep_num, ep.name), "title": ep.name, "plot": ep.overview.clone().unwrap_or_default(), "runtime": runtime, "rating": ep.rating.unwrap_or(0.0), "playcount": playcount, "file": format!("jellyfin://episode/{}", ep.id), "thumbnail": to_kodi_art(&ep.thumb_url(base_url)), "fanart": to_kodi_art(&ep.backdrop_url(base_url)), "art": { "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)), } })); } } let total = episodes_out.len(); let (start, end) = parse_limits(req.params.as_ref(), total); let paged = episodes_out[start..end].to_vec(); json!({ "jsonrpc": "2.0", "result": { "limits": {"start": start, "end": end, "total": total}, "episodes": paged }, "id": id }) } else { let (start, limit) = parse_limits_raw(req.params.as_ref(), 100); let (total, eps) = state.client.fetch_episodes_paged(None, None, start, limit).await; let mut episodes_out = Vec::new(); for ep in eps { let s_num = ep.parent_index_number.unwrap_or(1); let ep_num = ep.index_number.unwrap_or(1); let epid = state.id_map.get_or_create(&ep.id).await; let show_tid = if let Some(ref sid) = ep.series_id { state.id_map.get_or_create(sid).await } else { 0 }; let runtime = ep.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0); let playcount = if ep.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 }; episodes_out.push(json!({ "episodeid": epid, "tvshowid": show_tid, "season": s_num, "episode": ep_num, "label": format!("{}x{:02}. {}", s_num, ep_num, ep.name), "title": ep.name, "plot": ep.overview.clone().unwrap_or_default(), "runtime": runtime, "rating": ep.rating.unwrap_or(0.0), "playcount": playcount, "file": format!("jellyfin://episode/{}", ep.id), "thumbnail": to_kodi_art(&ep.thumb_url(base_url)), "fanart": to_kodi_art(&ep.backdrop_url(base_url)), "art": { "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)), } })); } let actual_end = start + episodes_out.len(); json!({ "jsonrpc": "2.0", "result": { "limits": {"start": start, "end": actual_end, "total": total}, "episodes": episodes_out }, "id": id }) } } "VideoLibrary.GetEpisodeDetails" => { let eid_opt = req.params.as_ref().and_then(|p| { p.get("episodeid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) .or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))) }); let mut details = json!({}); if let Some(eid) = eid_opt { if let Some(jellyfin_id) = state.id_map.get_str(eid).await { let cache = state.episodes_cache.read().await; if let Some(ep) = cache.values().flatten().find(|e| e.id == jellyfin_id) { let base_url = &state.client.config.server_url; let s_num = ep.parent_index_number.unwrap_or(1); let ep_num = ep.index_number.unwrap_or(1); let runtime = ep.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0); let playcount = if ep.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 }; details = json!({ "episodeid": eid, "season": s_num, "episode": ep_num, "label": format!("{}x{:02}. {}", s_num, ep_num, ep.name), "title": ep.name, "plot": ep.overview.clone().unwrap_or_default(), "runtime": runtime, "rating": ep.rating.unwrap_or(0.0), "playcount": playcount, "file": format!("jellyfin://episode/{}", ep.id), "thumbnail": to_kodi_art(&ep.thumb_url(base_url)), "fanart": to_kodi_art(&ep.backdrop_url(base_url)), "art": { "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)), } }); } } } json!({ "jsonrpc": "2.0", "result": { "episodedetails": details }, "id": id }) } "VideoLibrary.GetRecentlyAddedEpisodes" => { json!({ "jsonrpc": "2.0", "result": { "limits": {"start": 0, "end": 0, "total": 0}, "episodes": [] }, "id": id }) } "VideoLibrary.GetGenres" | "AudioLibrary.GetGenres" => { json!({ "jsonrpc": "2.0", "result": { "limits": {"start": 0, "end": 0, "total": 0}, "genres": [] }, "id": id }) } "AudioLibrary.GetArtists" => { let lib = state.client.get_library().await; let base_url = &state.client.config.server_url; let mut artists = Vec::new(); for a in &lib.music_artists { let aid = state.id_map.get_or_create(&a.id).await; artists.push(json!({ "artistid": aid, "artist": a.name, "label": a.name, "thumbnail": to_kodi_art(&a.square_url(base_url)), "fanart": to_kodi_art(&a.backdrop_url(base_url)), "art": { "thumb": to_kodi_art(&a.square_url(base_url)), "fanart": to_kodi_art(&a.backdrop_url(base_url)), } })); } let total = artists.len(); let (start, end) = parse_limits(req.params.as_ref(), total); let paged = artists[start..end].to_vec(); json!({ "jsonrpc": "2.0", "result": { "limits": {"start": start, "end": end, "total": total}, "artists": paged }, "id": id }) } "AudioLibrary.GetAlbums" => { let lib = state.client.get_library().await; let base_url = &state.client.config.server_url; let mut albums = Vec::new(); for al in &lib.music_albums { let al_id = state.id_map.get_or_create(&al.id).await; albums.push(json!({ "albumid": al_id, "title": al.name, "label": al.name, "artist": al.artist_name.as_deref().unwrap_or(""), "year": al.year.unwrap_or(0), "thumbnail": to_kodi_art(&al.square_url(base_url)), "fanart": to_kodi_art(&al.backdrop_url(base_url)), "art": { "thumb": to_kodi_art(&al.square_url(base_url)), "fanart": to_kodi_art(&al.backdrop_url(base_url)), } })); } let total = albums.len(); let (start, end) = parse_limits(req.params.as_ref(), total); let paged = albums[start..end].to_vec(); json!({ "jsonrpc": "2.0", "result": { "limits": {"start": start, "end": end, "total": total}, "albums": paged }, "id": id }) } "AudioLibrary.GetRecentlyAddedAlbums" => { let lib = state.client.get_library().await; let base_url = &state.client.config.server_url; let mut albums = Vec::new(); for al in &lib.latest_music { let al_id = state.id_map.get_or_create(&al.id).await; albums.push(json!({ "albumid": al_id, "title": al.name, "label": al.name, "artist": al.artist_name.as_deref().unwrap_or(""), "year": al.year.unwrap_or(0), "thumbnail": to_kodi_art(&al.square_url(base_url)), "fanart": to_kodi_art(&al.backdrop_url(base_url)), "art": { "thumb": to_kodi_art(&al.square_url(base_url)), "fanart": to_kodi_art(&al.backdrop_url(base_url)), } })); } let total = albums.len(); let (start, end) = parse_limits(req.params.as_ref(), total); let paged = albums[start..end].to_vec(); json!({ "jsonrpc": "2.0", "result": { "limits": {"start": start, "end": end, "total": total}, "albums": paged }, "id": id }) } "AudioLibrary.GetSongs" => { let al_opt = req.params.as_ref().and_then(|p| { p.get("albumid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) .or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))) }); let ar_opt = req.params.as_ref().and_then(|p| { p.get("artistid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) }); let album_jf = if let Some(aid) = al_opt { state.id_map.get_str(aid).await } else { None }; let artist_jf = if let Some(arid) = ar_opt { state.id_map.get_str(arid).await } else { None }; let (start, limit) = parse_limits_raw(req.params.as_ref(), 100); let (total, songs) = state.client.fetch_songs_paged(album_jf.as_deref(), artist_jf.as_deref(), start, limit).await; let base_url = &state.client.config.server_url; let mut songs_out = Vec::new(); for s in songs { let songid = state.id_map.get_or_create(&s.id).await; let duration = s.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0); let track = s.index_number.unwrap_or(0); let disc = s.parent_index_number.unwrap_or(1); let artist = s.artist_name.as_deref().unwrap_or("Onbekend"); let album = s.album.as_deref().unwrap_or(""); let file = format!("jellyfin://audio/{}", s.id); songs_out.push(json!({ "songid": songid, "label": s.name, "title": s.name, "artist": [artist], "album": album, "duration": duration, "track": track, "disc": disc, "year": s.year.unwrap_or(0), "file": file, "thumbnail": to_kodi_art(&s.square_url(base_url)), "fanart": to_kodi_art(&s.backdrop_url(base_url)), "art": { "thumb": to_kodi_art(&s.square_url(base_url)), "fanart": to_kodi_art(&s.backdrop_url(base_url)) } })); } let actual_end = start + songs_out.len(); json!({ "jsonrpc": "2.0", "result": { "limits": {"start": start, "end": actual_end, "total": total}, "songs": songs_out }, "id": id }) } "AudioLibrary.GetSongDetails" => { let sid_opt = req.params.as_ref().and_then(|p| { p.get("songid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) .or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))) }); let mut details = json!({}); if let Some(sid) = sid_opt { if let Some(jellyfin_id) = state.id_map.get_str(sid).await { details = json!({ "songid": sid, "file": format!("jellyfin://audio/{}", jellyfin_id) }); } } json!({ "jsonrpc": "2.0", "result": { "songdetails": details }, "id": id }) } "AudioLibrary.GetAlbumDetails" => { let aid_opt = req.params.as_ref().and_then(|p| { p.get("albumid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) .or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))) }); let mut details = json!({}); if let Some(aid) = aid_opt { if let Some(jellyfin_id) = state.id_map.get_str(aid).await { let lib = state.client.get_library().await; if let Some(al) = lib.music_albums.iter().find(|x| x.id == jellyfin_id) { let base_url = &state.client.config.server_url; details = json!({ "albumid": aid, "title": al.name, "label": al.name, "artist": al.artist_name.as_deref().unwrap_or(""), "year": al.year.unwrap_or(0), "thumbnail": to_kodi_art(&al.square_url(base_url)), "fanart": to_kodi_art(&al.backdrop_url(base_url)), "art": { "thumb": to_kodi_art(&al.square_url(base_url)), "fanart": to_kodi_art(&al.backdrop_url(base_url)), } }); } } } json!({ "jsonrpc": "2.0", "result": { "albumdetails": details }, "id": id }) } "AudioLibrary.GetArtistDetails" => { let aid_opt = req.params.as_ref().and_then(|p| { p.get("artistid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) .or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))) }); let mut details = json!({}); if let Some(aid) = aid_opt { if let Some(jellyfin_id) = state.id_map.get_str(aid).await { let lib = state.client.get_library().await; if let Some(ar) = lib.music_artists.iter().find(|x| x.id == jellyfin_id) { let base_url = &state.client.config.server_url; details = json!({ "artistid": aid, "artist": ar.name, "label": ar.name, "thumbnail": to_kodi_art(&ar.square_url(base_url)), "fanart": to_kodi_art(&ar.backdrop_url(base_url)), "art": { "thumb": to_kodi_art(&ar.square_url(base_url)), "fanart": to_kodi_art(&ar.backdrop_url(base_url)), } }); } } } json!({ "jsonrpc": "2.0", "result": { "artistdetails": details }, "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 }) } #[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://www.youtube.com/watch?v=6G45Qk0HQNY" ); assert_eq!( normalize_playback_url("https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"), "https://www.youtube.com/watch?v=6G45Qk0HQNY" ); assert_eq!( normalize_playback_url("https://youtu.be/6G45Qk0HQNY?si=something"), "https://www.youtube.com/watch?v=6G45Qk0HQNY" ); } #[tokio::test] async fn test_video_library_get_movies_and_details() { let config = crate::config::PlayerConfig::default(); let player = PlayerController::new(config); let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let nav_tx = RemoteNavSender::from(tx); let jf_config = crate::config::JellyfinConfig { server_url: "http://localhost:8096".into(), username: "test".into(), password: None, client_name: "test".into(), device_name: "test".into(), device_id: "test".into(), }; let client = JellyfinClient::new(jf_config); { let mut lib = client.library.write().await; lib.all_movies.push(MediaItem { id: "test-movie-uuid-1".into(), name: "The Matrix".into(), sort_name: Some("Matrix".into()), item_type: "Movie".into(), series_name: None, artist_name: None, genre: Some("Sci-Fi, Action".into()), index_number: None, parent_index_number: None, season_name: None, year: Some(1999), overview: Some("Neo takes the red pill".into()), rating: Some(8.7), runtime_ticks: Some(81600000000), played_percentage: Some(0.0), is_folder: false, has_primary_image: true, has_backdrop_image: true, parent_primary_image_item_id: None, parent_backdrop_item_id: None, ..Default::default() }); } let state = YatseServerState::new(player, nav_tx, client); let req = JsonRpcRequest { jsonrpc: Some("2.0".into()), method: "VideoLibrary.GetMovies".into(), params: Some(json!({"limits": {"start": 0, "end": 10}})), id: Some(json!(42)), }; let res = dispatch_rpc(&state, req).await; assert_eq!(res["id"], 42); let movies = res["result"]["movies"].as_array().expect("movies array"); assert_eq!(movies.len(), 1); assert_eq!(movies[0]["title"], "The Matrix"); assert_eq!(movies[0]["year"], 1999); let mid = movies[0]["movieid"].as_i64().expect("movieid"); assert!(mid > 0); // Test GetMovieDetails let req_details = JsonRpcRequest { jsonrpc: Some("2.0".into()), method: "VideoLibrary.GetMovieDetails".into(), params: Some(json!({"movieid": mid})), id: Some(json!(43)), }; let res_details = dispatch_rpc(&state, req_details).await; assert_eq!(res_details["result"]["moviedetails"]["title"], "The Matrix"); assert_eq!(res_details["result"]["moviedetails"]["movieid"], mid); } #[tokio::test] async fn test_video_library_tv_shows_seasons_episodes() { let config = crate::config::PlayerConfig::default(); let player = PlayerController::new(config); let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let nav_tx = RemoteNavSender::from(tx); let jf_config = crate::config::JellyfinConfig { server_url: "http://localhost:8096".into(), username: "test".into(), password: None, client_name: "test".into(), device_name: "test".into(), device_id: "test".into(), }; let client = JellyfinClient::new(jf_config); let show_id = "show-uuid-1"; { let mut lib = client.library.write().await; lib.all_shows.push(MediaItem { id: show_id.into(), name: "Breaking Bad".into(), sort_name: Some("Breaking Bad".into()), item_type: "Series".into(), series_name: None, artist_name: None, genre: Some("Drama, Crime".into()), index_number: None, parent_index_number: None, season_name: None, year: Some(2008), overview: Some("Chemistry teacher cooks meth".into()), rating: Some(9.5), runtime_ticks: None, played_percentage: None, is_folder: true, has_primary_image: true, has_backdrop_image: true, parent_primary_image_item_id: None, parent_backdrop_item_id: None, ..Default::default() }); } let state = YatseServerState::new(player, nav_tx, client); { let mut cache = state.episodes_cache.write().await; cache.insert(show_id.into(), vec![ MediaItem { id: "ep-uuid-1".into(), name: "Pilot".into(), sort_name: None, item_type: "Episode".into(), series_name: Some("Breaking Bad".into()), artist_name: None, genre: None, index_number: Some(1), parent_index_number: Some(1), season_name: Some("Season 1".into()), year: Some(2008), overview: Some("Walter White diagnosed".into()), rating: Some(9.0), runtime_ticks: Some(34800000000), played_percentage: None, is_folder: false, has_primary_image: true, has_backdrop_image: false, parent_primary_image_item_id: None, parent_backdrop_item_id: None, ..Default::default() }, MediaItem { id: "ep-uuid-2".into(), name: "Cat's in the Bag...".into(), sort_name: None, item_type: "Episode".into(), series_name: Some("Breaking Bad".into()), artist_name: None, genre: None, index_number: Some(2), parent_index_number: Some(1), season_name: Some("Season 1".into()), year: Some(2008), overview: Some("Disposing of Krazy-8".into()), rating: Some(8.7), runtime_ticks: Some(28800000000), played_percentage: None, is_folder: false, has_primary_image: true, has_backdrop_image: false, parent_primary_image_item_id: None, parent_backdrop_item_id: None, ..Default::default() }, ]); } // 1. GetTVShows let req_shows = JsonRpcRequest { jsonrpc: Some("2.0".into()), method: "VideoLibrary.GetTVShows".into(), params: None, id: Some(json!(1)), }; let res_shows = dispatch_rpc(&state, req_shows).await; let shows = res_shows["result"]["tvshows"].as_array().expect("shows"); assert_eq!(shows.len(), 1); assert_eq!(shows[0]["title"], "Breaking Bad"); let tid = shows[0]["tvshowid"].as_i64().expect("tid"); // 2. GetSeasons let req_seasons = JsonRpcRequest { jsonrpc: Some("2.0".into()), method: "VideoLibrary.GetSeasons".into(), params: Some(json!({"tvshowid": tid})), id: Some(json!(2)), }; let res_seasons = dispatch_rpc(&state, req_seasons).await; let seasons = res_seasons["result"]["seasons"].as_array().expect("seasons"); assert_eq!(seasons.len(), 1); assert_eq!(seasons[0]["season"], 1); assert_eq!(seasons[0]["episode"], 2); // 3. GetEpisodes let req_eps = JsonRpcRequest { jsonrpc: Some("2.0".into()), method: "VideoLibrary.GetEpisodes".into(), params: Some(json!({"tvshowid": tid, "season": 1})), id: Some(json!(3)), }; let res_eps = dispatch_rpc(&state, req_eps).await; let eps = res_eps["result"]["episodes"].as_array().expect("episodes"); assert_eq!(eps.len(), 2); assert_eq!(eps[0]["title"], "Pilot"); assert_eq!(eps[0]["episode"], 1); assert_eq!(eps[1]["title"], "Cat's in the Bag..."); assert_eq!(eps[1]["episode"], 2); } #[test] fn test_extract_addon_share_url() { let payload = json!({ "addonid": "script.yatse.kodi", "params": { "action": "share", "data": "https%3A%2F%2Fyoutube.com%2Fwatch%3Fv%3DeMEIt6_mGSI%26is%3DhEdy88z6Nhf7gRSE", "queue": "false", "type": "unresolvedurl" } }); let extracted = extract_addon_share_url(&payload).expect("extracted url"); assert_eq!( percent_decode(&extracted), "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/ 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/ 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/ let path3 = format!("/vfs/{}", percent_encode(&kodi_art)); let res3 = extract_image_url(&path3, None).expect("extracted 3"); assert_eq!(res3, original); } }