2402 lines
100 KiB
Rust
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);
|
|
}
|
|
}
|
|
|