Files
jellyfin-client/jellyfin-kiosk-rs/src/yatse.rs
T

2402 lines
100 KiB
Rust

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<RemoteNavAction>,
ctx: Arc<std::sync::Mutex<Option<eframe::egui::Context>>>,
}
impl RemoteNavSender {
pub fn new(tx: UnboundedSender<RemoteNavAction>, ctx: Arc<std::sync::Mutex<Option<eframe::egui::Context>>>) -> Self {
Self { tx, ctx }
}
pub fn send(&self, action: RemoteNavAction) -> Result<(), tokio::sync::mpsc::error::SendError<RemoteNavAction>> {
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<UnboundedSender<RemoteNavAction>> for RemoteNavSender {
fn from(tx: UnboundedSender<RemoteNavAction>) -> Self {
Self {
tx,
ctx: Arc::new(std::sync::Mutex::new(None)),
}
}
}
#[derive(Default, Clone)]
pub struct IdMap {
str_to_id: Arc<RwLock<HashMap<String, i64>>>,
id_to_str: Arc<RwLock<HashMap<i64, String>>>,
next_id: Arc<RwLock<i64>>,
}
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<String> {
let read = self.id_to_str.read().await;
read.get(&id).cloned()
}
}
#[derive(Clone)]
pub struct YatseServerState {
pub player: Arc<PlayerController>,
pub nav_tx: RemoteNavSender,
pub client: JellyfinClient,
pub id_map: IdMap,
pub episodes_cache: Arc<RwLock<HashMap<String, Vec<MediaItem>>>>,
}
impl YatseServerState {
pub fn new(player: Arc<PlayerController>, 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<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))
.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<String> {
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<YatseServerState>,
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<YatseServerState>,
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<String> {
// 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<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": {
"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::<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>,
) -> 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())));
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<u32, usize> = 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/<encoded>
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/<single-encoded-http-with-trailing-slash>
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/<kodi-art>
let path3 = format!("/vfs/{}", percent_encode(&kodi_art));
let res3 = extract_image_url(&path3, None).expect("extracted 3");
assert_eq!(res3, original);
}
}