feat(kiosk): bump v0.1.2 with video library support for movies, tv shows, and seasons

This commit is contained in:
bart
2026-11-05 18:46:32 +01:00
parent 4f2cdbd817
commit 3a407f90f7
8 changed files with 1321 additions and 115 deletions
+902 -11
View File
@@ -6,12 +6,15 @@ use axum::{
};
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, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteNavAction {
Up,
Down,
@@ -29,6 +32,7 @@ pub enum RemoteNavAction {
VolumeDown,
PlayPause,
Stop,
SendText(String),
}
#[derive(Clone)]
@@ -62,10 +66,80 @@ impl From<UnboundedSender<RemoteNavAction>> for RemoteNavSender {
}
}
#[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 { total } else { (start + end_val).min(total) };
return (start, end);
}
}
(0, total)
}
#[derive(Debug, Deserialize)]
@@ -546,16 +620,60 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
json!({"jsonrpc": "2.0", "result": {"speed": speed}, "id": id})
}
"Player.Open" => {
if let Some(params) = req.params {
if let Some(item) = params.get("item") {
if let Some(file) = item.get("file").and_then(|f| f.as_str()) {
let norm_url = normalize_playback_url(file);
tracing::info!("Player.Open requested file: '{}' -> normalized: '{}'", file, norm_url);
let p = state.player.clone();
tokio::spawn(async move {
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 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(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 file='{}' -> {}", file, stream_url);
let _ = p.play(&stream_url, "Media from 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!({
@@ -592,6 +710,20 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"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())))
@@ -887,6 +1019,16 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
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",
@@ -901,7 +1043,7 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"jsonrpc": "2.0",
"result": {
"sources": sources,
"limits": {"start": 0, "end": 1, "total": 1}
"limits": {"start": 0, "end": sources.len(), "total": sources.len()}
},
"id": id
})
@@ -916,6 +1058,546 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"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": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": 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": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": 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": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": 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": 0,
"watchedepisodes": 0,
"file": format!("jellyfin://show/{}", s.id),
"thumbnail": s.thumb_url(base_url),
"fanart": s.backdrop_url(base_url),
"art": {
"poster": s.poster_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumb": 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": 0,
"watchedepisodes": 0,
"file": format!("jellyfin://show/{}", s.id),
"thumbnail": s.thumb_url(base_url),
"fanart": s.backdrop_url(base_url),
"art": {
"poster": s.poster_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumb": 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": poster,
"fanart": backdrop,
"art": {
"poster": poster,
"fanart": backdrop,
"thumb": 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 mut episodes_out = 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 base_url = &state.client.config.server_url;
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": ep.thumb_url(base_url),
"fanart": ep.backdrop_url(base_url),
"art": {
"thumb": ep.thumb_url(base_url),
"poster": ep.poster_url(base_url),
"fanart": 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
})
}
"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": ep.thumb_url(base_url),
"fanart": ep.backdrop_url(base_url),
"art": {
"thumb": ep.thumb_url(base_url),
"poster": ep.poster_url(base_url),
"fanart": 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": a.square_url(base_url),
"fanart": a.backdrop_url(base_url),
"art": {
"thumb": a.square_url(base_url),
"fanart": 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": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"art": {
"thumb": al.square_url(base_url),
"fanart": 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": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"art": {
"thumb": al.square_url(base_url),
"fanart": 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" => {
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": 0, "end": 0, "total": 0},
"songs": []
},
"id": id
})
}
_ => {
json!({
"jsonrpc": "2.0",
@@ -963,4 +1645,213 @@ mod tests {
"https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"
);
}
#[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,
});
}
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,
});
}
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,
},
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,
},
]);
}
// 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);
}
}