feat(kiosk): bump v0.1.2 with video library support for movies, tv shows, and seasons
This commit is contained in:
+902
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user