diff --git a/jellyfin-kiosk-rs/Cargo.lock b/jellyfin-kiosk-rs/Cargo.lock index 3191d54..9fa6fdf 100644 --- a/jellyfin-kiosk-rs/Cargo.lock +++ b/jellyfin-kiosk-rs/Cargo.lock @@ -1460,7 +1460,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jellyfin-kiosk-rs" -version = "0.1.3" +version = "0.1.4" dependencies = [ "axum", "eframe", diff --git a/jellyfin-kiosk-rs/Cargo.toml b/jellyfin-kiosk-rs/Cargo.toml index e64f904..d5ffcad 100644 --- a/jellyfin-kiosk-rs/Cargo.toml +++ b/jellyfin-kiosk-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jellyfin-kiosk-rs" -version = "0.1.3" +version = "0.1.4" edition = "2021" [dependencies] diff --git a/jellyfin-kiosk-rs/jellyfin-kiosk b/jellyfin-kiosk-rs/jellyfin-kiosk index 3d7a2ab..92e0192 100755 Binary files a/jellyfin-kiosk-rs/jellyfin-kiosk and b/jellyfin-kiosk-rs/jellyfin-kiosk differ diff --git a/jellyfin-kiosk-rs/kiosk.log b/jellyfin-kiosk-rs/kiosk.log new file mode 100644 index 0000000..8b05c35 --- /dev/null +++ b/jellyfin-kiosk-rs/kiosk.log @@ -0,0 +1,12 @@ +2026-11-05T21:13:59.466559Z  INFO jellyfin_kiosk_rs: Starting Jellyfin HTPC Kiosk (Rust)... +2026-11-05T21:13:59.467206Z  INFO jellyfin_kiosk_rs::config: Loaded config from ../config.yaml +2026-11-05T21:13:59.474938Z  INFO jellyfin_kiosk_rs::player: libmpv initialized successfully (vo=libmpv, hwdec=auto) +2026-11-05T21:13:59.549439Z  INFO jellyfin_kiosk_rs::yatse: Yatse Kodi TCP JSON-RPC socket server listening on 0.0.0.0:9090 +2026-11-05T21:13:59.549444Z  INFO jellyfin_kiosk_rs: Yatse Kodi JSON-RPC server listening on http://0.0.0.0:8080 +2026-11-05T21:13:59.549923Z  INFO jellyfin_kiosk_rs::eventserver: Kodi EventServer listening on UDP 0.0.0.0:9777 +2026-11-05T21:13:59.551471Z  INFO jellyfin_kiosk_rs: Published Zeroconf services for Yatse discovery: 'Kodi (bart-laptop)' +2026-11-05T21:13:59.691500Z  INFO winit::platform_impl::linux::x11::window: Guessed window scale factor: 1 +2026-11-05T21:13:59.729454Z  INFO jellyfin_kiosk_rs::youtube: Loaded cached YouTube token from youtube_token.json +Cannot load libcuda.so.1 +2026-11-05T21:13:59.854273Z  INFO jellyfin_kiosk_rs::player: libmpv OpenGL render context created successfully! +2026-11-05T21:13:59.916581Z  INFO jellyfin_kiosk_rs::jellyfin: Authenticated successfully with Jellyfin as bart diff --git a/jellyfin-kiosk-rs/src/app.rs b/jellyfin-kiosk-rs/src/app.rs index cb66f41..39d98b4 100644 --- a/jellyfin-kiosk-rs/src/app.rs +++ b/jellyfin-kiosk-rs/src/app.rs @@ -135,6 +135,7 @@ pub struct KioskApp { was_playing: bool, playback_osd_open: bool, playback_osd_idx: usize, + playback_osd_until: Option, last_seen_volume: f64, volume_hud_until: Option, osd_notice: Option<(String, Instant)>, @@ -236,6 +237,7 @@ impl KioskApp { was_playing: false, playback_osd_open: false, playback_osd_idx: 0, + playback_osd_until: None, last_seen_volume: 100.0, volume_hud_until: None, osd_notice: None, @@ -1070,19 +1072,24 @@ impl KioskApp { } else { self.playback_osd_idx - 1 }; + self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4)); } RemoteNavAction::Right => { self.playback_osd_idx = (self.playback_osd_idx + 1) % 10; + self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4)); } RemoteNavAction::Up | RemoteNavAction::Down => { // Kodi behavior: Up or Down closes the OSD control bar self.playback_osd_open = false; + self.playback_osd_until = None; } RemoteNavAction::Select => { self.execute_osd_action(self.playback_osd_idx); + self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4)); } RemoteNavAction::Back => { self.playback_osd_open = false; + self.playback_osd_until = None; } _ => {} } @@ -1109,13 +1116,16 @@ impl KioskApp { self.osd_notice = Some(("⏪ -60s".to_string(), Instant::now() + Duration::from_millis(1500))); } RemoteNavAction::Select => { + // Single Select/OK opens the OSD and starts the auto-hide timer without pausing! self.playback_osd_open = true; self.playback_osd_idx = 2; // Pause/Play + self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4)); } RemoteNavAction::Back => { // Return to library browser while media keeps playing in background self.view_mode = ViewMode::Browser; self.playback_osd_open = false; + self.playback_osd_until = None; let p = self.player.clone(); tokio::spawn(async move { p.set_video_visible(false).await; @@ -1651,15 +1661,27 @@ impl eframe::App for KioskApp { self.view_mode = ViewMode::Player; self.playback_osd_open = true; self.playback_osd_idx = 2; // Pause/Play + self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4)); self.last_playing_title = current_title; } else if !is_playing && self.was_playing { self.view_mode = ViewMode::Browser; self.playback_osd_open = false; + self.playback_osd_until = None; self.last_playing_title.clear(); } self.was_playing = is_playing; - if is_playing || self.volume_hud_until.is_some() || self.osd_notice.is_some() || self.folder_loading { + // Auto-hide playback OSD for video + if self.playback_osd_open { + if let Some(until) = self.playback_osd_until { + if Instant::now() >= until { + self.playback_osd_open = false; + self.playback_osd_until = None; + } + } + } + + if is_playing || self.volume_hud_until.is_some() || self.playback_osd_until.is_some() || self.osd_notice.is_some() || self.folder_loading { ctx.request_repaint_after(Duration::from_millis(100)); } diff --git a/jellyfin-kiosk-rs/src/jellyfin.rs b/jellyfin-kiosk-rs/src/jellyfin.rs index f155852..c635f45 100644 --- a/jellyfin-kiosk-rs/src/jellyfin.rs +++ b/jellyfin-kiosk-rs/src/jellyfin.rs @@ -26,7 +26,7 @@ pub enum PlayableTarget { OpenFolder { folder_id: String, name: String }, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct MediaItem { pub id: String, pub name: String, @@ -48,6 +48,11 @@ pub struct MediaItem { pub has_backdrop_image: bool, pub parent_primary_image_item_id: Option, pub parent_backdrop_item_id: Option, + pub recursive_item_count: Option, + pub child_count: Option, + pub series_id: Option, + pub album_id: Option, + pub album: Option, } impl MediaItem { @@ -604,22 +609,95 @@ impl JellyfinClient { if let Ok(headers) = self.get_authed_headers().await { let user_id = self.user_id.read().await.clone().unwrap_or_default(); let url = format!( - "{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres", + "{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId", base_url, show_id, user_id ); return fetch_endpoint(&self.client, &url, &headers).await; } Vec::new() } + + pub async fn fetch_episodes_paged( + &self, + show_id: Option<&str>, + season: Option, + start: usize, + limit: usize, + ) -> (usize, Vec) { + let base_url = self.config.server_url.trim_end_matches('/'); + if let Ok(headers) = self.get_authed_headers().await { + let user_id = self.user_id.read().await.clone().unwrap_or_default(); + let url = if let Some(sid) = show_id { + if let Some(s_num) = season { + format!( + "{}/Shows/{}/Episodes?userId={}&season={}&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId", + base_url, sid, user_id, s_num, start, limit + ) + } else { + format!( + "{}/Shows/{}/Episodes?userId={}&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId", + base_url, sid, user_id, start, limit + ) + } + } else { + format!( + "{}/Users/{}/Items?includeItemTypes=Episode&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId", + base_url, user_id, start, limit + ) + }; + return fetch_endpoint_paged(&self.client, &url, &headers).await; + } + (0, Vec::new()) + } + + pub async fn fetch_songs_paged( + &self, + album_id: Option<&str>, + artist_id: Option<&str>, + start: usize, + limit: usize, + ) -> (usize, Vec) { + let base_url = self.config.server_url.trim_end_matches('/'); + if let Ok(headers) = self.get_authed_headers().await { + let user_id = self.user_id.read().await.clone().unwrap_or_default(); + let url = if let Some(aid) = album_id { + format!( + "{}/Users/{}/Items?parentId={}&includeItemTypes=Audio&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks", + base_url, user_id, aid, start, limit + ) + } else if let Some(arid) = artist_id { + format!( + "{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks", + base_url, user_id, arid, start, limit + ) + } else { + format!( + "{}/Users/{}/Items?includeItemTypes=Audio&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks", + base_url, user_id, start, limit + ) + }; + return fetch_endpoint_paged(&self.client, &url, &headers).await; + } + (0, Vec::new()) + } } async fn fetch_endpoint(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> Vec { + fetch_endpoint_paged(client, url, headers).await.1 +} + +async fn fetch_endpoint_paged(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> (usize, Vec) { if let Ok(resp) = client.get(url).headers(headers.clone()).send().await { if let Ok(json) = resp.json::().await { - return parse_items(&json); + let items = parse_items(&json); + let total = json["TotalRecordCount"] + .as_u64() + .map(|n| n as usize) + .unwrap_or(items.len()); + return (total, items); } } - Vec::new() + (0, Vec::new()) } fn parse_items(json: &serde_json::Value) -> Vec { @@ -630,7 +708,16 @@ fn parse_items(json: &serde_json::Value) -> Vec { let name = it["Name"].as_str().unwrap_or("Onbekend").to_string(); let item_type = it["Type"].as_str().unwrap_or("Video").to_string(); let series_name = it["SeriesName"].as_str().map(|s| s.to_string()); - let artist_name = it["AlbumArtist"].as_str().or_else(|| it["Artist"].as_str()).map(|s| s.to_string()); + let artist_name = it["AlbumArtist"] + .as_str() + .or_else(|| it["Artist"].as_str()) + .map(|s| s.to_string()) + .or_else(|| { + it["Artists"].as_array().and_then(|arr| arr.first().and_then(|a| a.as_str())).map(|s| s.to_string()) + }) + .or_else(|| { + it["ArtistItems"].as_array().and_then(|arr| arr.first().and_then(|a| a["Name"].as_str())).map(|s| s.to_string()) + }); let genre = it["Genres"].as_array().and_then(|arr| { let list: Vec<&str> = arr.iter().filter_map(|g| g.as_str()).collect(); if list.is_empty() { @@ -661,6 +748,11 @@ fn parse_items(json: &serde_json::Value) -> Vec { let index_number = it["IndexNumber"].as_u64().map(|n| n as u32); let parent_index_number = it["ParentIndexNumber"].as_u64().map(|n| n as u32); let season_name = it["SeasonName"].as_str().map(|s| s.to_string()); + let recursive_item_count = it["RecursiveItemCount"].as_u64().map(|n| n as u32); + let child_count = it["ChildCount"].as_u64().map(|n| n as u32); + let series_id = it["SeriesId"].as_str().map(|s| s.to_string()); + let album_id = it["AlbumId"].as_str().map(|s| s.to_string()); + let album = it["Album"].as_str().map(|s| s.to_string()); if !id.is_empty() { out.push(MediaItem { @@ -684,6 +776,11 @@ fn parse_items(json: &serde_json::Value) -> Vec { has_backdrop_image, parent_primary_image_item_id, parent_backdrop_item_id, + recursive_item_count, + child_count, + series_id, + album_id, + album, }); } } diff --git a/jellyfin-kiosk-rs/src/player.rs b/jellyfin-kiosk-rs/src/player.rs index 5ed90f8..bf4583e 100644 --- a/jellyfin-kiosk-rs/src/player.rs +++ b/jellyfin-kiosk-rs/src/player.rs @@ -198,7 +198,7 @@ impl PlayerController { "yt-dlp" }; mpv.set_option_string("script-opts", &format!("ytdl_hook-ytdl_path={}", ytdlp_path)); - mpv.set_option_string("ytdl-format", "bestvideo[height<=?1080]+bestaudio/best"); + mpv.set_option_string("ytdl-format", "bestvideo[height<=?1080][vcodec!*='av01']+bestaudio/bestvideo[height<=?1080]+bestaudio/best"); mpv.set_option_string("ytdl-raw-options", "no-playlist="); let init_res = unsafe { libmpv_sys::mpv_initialize(raw_mpv) }; diff --git a/jellyfin-kiosk-rs/src/yatse.rs b/jellyfin-kiosk-rs/src/yatse.rs index f105435..9d36444 100644 --- a/jellyfin-kiosk-rs/src/yatse.rs +++ b/jellyfin-kiosk-rs/src/yatse.rs @@ -135,13 +135,35 @@ fn parse_limits(params: Option<&Value>, total: usize) -> (usize, usize) { 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) }; + 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)] @@ -207,10 +229,30 @@ pub fn normalize_playback_url(file: &str) -> String { if let Some(query) = trimmed.split('?').nth(1) { let decoded = percent_decode(query); if decoded.starts_with("http://") || decoded.starts_with("https://") { - return decoded; + return normalize_playback_url(&decoded); } if let Some(url_part) = decoded.split("url=").nth(1) { - return percent_decode(url_part); + 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); } } } @@ -680,6 +722,7 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { 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(); @@ -712,14 +755,28 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { 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 file='{}' -> {}", file, stream_url); + 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); @@ -1307,7 +1364,8 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { "rating": s.rating.unwrap_or(0.0), "plot": s.overview.clone().unwrap_or_default(), "genre": genres, - "episode": 0, + "episode": s.recursive_item_count.unwrap_or(1), + "season": s.child_count.unwrap_or(1), "watchedepisodes": 0, "file": format!("jellyfin://show/{}", s.id), "thumbnail": s.thumb_url(base_url), @@ -1352,7 +1410,8 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { "rating": s.rating.unwrap_or(0.0), "plot": s.overview.clone().unwrap_or_default(), "genre": genres, - "episode": 0, + "episode": s.recursive_item_count.unwrap_or(1), + "season": s.child_count.unwrap_or(1), "watchedepisodes": 0, "file": format!("jellyfin://show/{}", s.id), "thumbnail": s.thumb_url(base_url), @@ -1457,8 +1516,9 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { p.get("season").and_then(|v| v.as_u64().map(|n| n as u32)) }); - let mut episodes_out = Vec::new(); + 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; @@ -1473,7 +1533,6 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { } }; - 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 { @@ -1508,18 +1567,64 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { })); } } + 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": 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 actual_end = start + episodes_out.len(); + json!({ + "jsonrpc": "2.0", + "result": { + "limits": {"start": start, "end": actual_end, "total": total}, + "episodes": episodes_out + }, + "id": id + }) } - 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| { @@ -1682,11 +1787,155 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { }) } "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": s.square_url(base_url), + "fanart": s.backdrop_url(base_url), + "art": { + "thumb": s.square_url(base_url), + "fanart": s.backdrop_url(base_url) + } + })); + } + + let actual_end = start + songs_out.len(); json!({ "jsonrpc": "2.0", "result": { - "limits": {"start": 0, "end": 0, "total": 0}, - "songs": [] + "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": al.square_url(base_url), + "fanart": al.backdrop_url(base_url), + "art": { + "thumb": al.square_url(base_url), + "fanart": 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": ar.square_url(base_url), + "fanart": ar.backdrop_url(base_url), + "art": { + "thumb": ar.square_url(base_url), + "fanart": ar.backdrop_url(base_url), + } + }); + } + } + } + json!({ + "jsonrpc": "2.0", + "result": { + "artistdetails": details }, "id": id }) @@ -1731,11 +1980,15 @@ mod tests { ); assert_eq!( normalize_playback_url("plugin://plugin.video.sendtokodi/?https%3A%2F%2Fyoutube.com%2Fwatch%3Fv%3D6G45Qk0HQNY"), - "https://youtube.com/watch?v=6G45Qk0HQNY" + "https://www.youtube.com/watch?v=6G45Qk0HQNY" ); assert_eq!( normalize_playback_url("https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"), - "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" ); } @@ -1778,6 +2031,7 @@ mod tests { has_backdrop_image: true, parent_primary_image_item_id: None, parent_backdrop_item_id: None, + ..Default::default() }); } @@ -1851,6 +2105,7 @@ mod tests { has_backdrop_image: true, parent_primary_image_item_id: None, parent_backdrop_item_id: None, + ..Default::default() }); } @@ -1880,6 +2135,7 @@ mod tests { has_backdrop_image: false, parent_primary_image_item_id: None, parent_backdrop_item_id: None, + ..Default::default() }, MediaItem { id: "ep-uuid-2".into(), @@ -1902,6 +2158,7 @@ mod tests { has_backdrop_image: false, parent_primary_image_item_id: None, parent_backdrop_item_id: None, + ..Default::default() }, ]); } diff --git a/jellyfin-kiosk-rs/src/youtube.rs b/jellyfin-kiosk-rs/src/youtube.rs index 239faab..b6a112e 100644 --- a/jellyfin-kiosk-rs/src/youtube.rs +++ b/jellyfin-kiosk-rs/src/youtube.rs @@ -466,6 +466,11 @@ impl YouTubeClient { has_backdrop_image: false, parent_primary_image_item_id: thumb, parent_backdrop_item_id: None, + recursive_item_count: None, + child_count: None, + series_id: None, + album_id: None, + album: None, }); } } @@ -545,6 +550,11 @@ impl YouTubeClient { has_backdrop_image: false, parent_primary_image_item_id: thumb, parent_backdrop_item_id: None, + recursive_item_count: None, + child_count: None, + series_id: None, + album_id: None, + album: None, }); } } @@ -648,6 +658,11 @@ impl YouTubeClient { has_backdrop_image: false, parent_primary_image_item_id: thumb, parent_backdrop_item_id: None, + recursive_item_count: None, + child_count: None, + series_id: None, + album_id: None, + album: None, }); } } diff --git a/jellyfin-kiosk-rs/youtube_token.json b/jellyfin-kiosk-rs/youtube_token.json index 436e29d..e754e3f 100644 --- a/jellyfin-kiosk-rs/youtube_token.json +++ b/jellyfin-kiosk-rs/youtube_token.json @@ -1,6 +1,6 @@ { - "access_token": "ya29.a0AehfTOkVjtpy5kB4TQRuD7E0MXJAKSipRjAkysg0HnjvBvZlnb1y2WQ6UCSPxOLPliLxdri6A9AB_MlgMPHp1AXnsKHXL3dA_tkVYp3yuAhud4nUCniG0WDpgUpJ_paOAGYf5vtiJqDprd7W_8612JfnVwKW2ymR1ptNJpJ-aZivVvMsebsJcjDruq05haZGvmQtTkLTaCgYKAYESARESFQHGX2MiCxxRMG9_Lvhm7KGNtUmMIw0207", + "access_token": "ya29.a0AehfTOmzi40Xcd4HJsYRCt-r2ZiRc2jDoynMSoynkK2pi5hAZEbmdbxN6Vid2ka9mE3rmEYafFlYoP9YZ7h4GjuGHE_nBnvLQNRHGtZCXbGKjmNc31QREZTbxc4uITVv-1VrNARf-_pgVOPywGNCfkinq8LbeDDT5wSX3Xs98J3bLpxccN9ZTErVFYMl_1BNXi_wWDPZaCgYKAZsSARESFQHGX2MiHjn2bDhDuW0uFN8u1498hw0207", "refresh_token": "1//09y6yuAbCKpGPCgYIARAAGAkSNwF-L9Ira4vVAsGF3K-oU7bEma5ZfUMy87MfInMEA_RvnDhxi_koUBGHGuLLAR5tbB9UZ6jcQOk", - "expires_at": 1793817948, + "expires_at": 1793916488, "token_type": "Bearer" } \ No newline at end of file