diff --git a/jellyfin-kiosk-rs/Cargo.lock b/jellyfin-kiosk-rs/Cargo.lock index f2e295c..105e79e 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.0" +version = "0.1.2" dependencies = [ "axum", "eframe", diff --git a/jellyfin-kiosk-rs/Cargo.toml b/jellyfin-kiosk-rs/Cargo.toml index 91da08d..13cc2db 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.0" +version = "0.1.2" edition = "2021" [dependencies] diff --git a/jellyfin-kiosk-rs/jellyfin-kiosk b/jellyfin-kiosk-rs/jellyfin-kiosk index fa132f9..451a1c8 100755 Binary files a/jellyfin-kiosk-rs/jellyfin-kiosk and b/jellyfin-kiosk-rs/jellyfin-kiosk differ diff --git a/jellyfin-kiosk-rs/src/app.rs b/jellyfin-kiosk-rs/src/app.rs index e5ae1e4..9d7a381 100644 --- a/jellyfin-kiosk-rs/src/app.rs +++ b/jellyfin-kiosk-rs/src/app.rs @@ -178,6 +178,9 @@ pub struct KioskApp { youtube_search_results: Vec, youtube_loading: bool, youtube_query: String, + youtube_header_focus: bool, + #[allow(dead_code)] + youtube_header_idx: usize, nav_rx: UnboundedReceiver, event_tx: UnboundedSender, @@ -264,6 +267,8 @@ impl KioskApp { youtube_search_results: Vec::new(), youtube_loading: false, youtube_query: String::new(), + youtube_header_focus: false, + youtube_header_idx: 0, nav_rx, event_tx, event_rx, @@ -579,6 +584,139 @@ impl KioskApp { }); } + #[allow(dead_code)] + fn render_youtube_login_screen(&mut self, ui: &mut egui::Ui) { + let is_content_focused = self.focus_area == FocusArea::Content; + let card_w = ui.available_width().min(680.0); + let card_h = 360.0; + + if self.youtube_auth_code.is_some() || self.youtube_auth_pending { + ui.ctx().request_repaint_after(Duration::from_millis(1000)); + } + + ui.vertical_centered(|ui| { + ui.add_space(32.0); + + let (card_rect, _) = ui.allocate_exact_size(Vec2::new(card_w, card_h), egui::Sense::hover()); + let border_stroke = if is_content_focused { + Stroke::new(2.5_f32, Color32::from_rgb(0, 184, 245)) + } else { + Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(255, 255, 255, 25)) + }; + + ui.painter().rect_filled(card_rect, Rounding::same(16.0), Color32::from_rgb(18, 24, 34)); + ui.painter().rect_stroke(card_rect, Rounding::same(16.0), border_stroke); + + ui.allocate_new_ui(egui::UiBuilder::new().max_rect(card_rect.shrink(28.0)), |card_ui| { + card_ui.vertical_centered(|ui| { + if !self.youtube_client.is_configured() { + ui.label(egui::RichText::new("âš ī¸").size(48.0)); + ui.add_space(10.0); + ui.label(egui::RichText::new("YouTube Niet Geconfigureerd").size(24.0).strong().color(Color32::WHITE)); + ui.add_space(12.0); + ui.label( + egui::RichText::new("Om in te loggen op YouTube, vul 'client_id' en 'client_secret' in onder 'youtube:' in config.yaml.") + .size(16.0) + .color(Color32::from_rgb(170, 185, 205)), + ); + } else if let Some(ref code_info) = self.youtube_auth_code { + ui.label(egui::RichText::new("📱").size(44.0)); + ui.add_space(6.0); + ui.label(egui::RichText::new("YouTube Account Koppelen").size(24.0).strong().color(Color32::WHITE)); + ui.add_space(8.0); + ui.label(egui::RichText::new("1. Ga op je telefoon of computer naar:").size(15.0).color(Color32::from_rgb(190, 200, 215))); + ui.label( + egui::RichText::new(&code_info.verification_url) + .size(22.0) + .strong() + .color(Color32::from_rgb(0, 190, 250)), + ); + ui.add_space(10.0); + ui.label(egui::RichText::new("2. Voer deze koppelcode in:").size(15.0).color(Color32::from_rgb(190, 200, 215))); + ui.label( + egui::RichText::new(&code_info.user_code) + .size(38.0) + .strong() + .color(Color32::from_rgb(255, 215, 0)), + ); + ui.add_space(10.0); + ui.label( + egui::RichText::new("âŗ Wachten op autorisatie in browser... Zodra je inlogt laadt de kiosk direct je video's.") + .size(13.0) + .italics() + .color(Color32::LIGHT_GRAY), + ); + ui.add_space(14.0); + let cancel_btn = egui::Button::new( + egui::RichText::new("❌ Annuleren (Druk Terug / Back op Yatse)") + .size(15.0) + .strong() + .color(Color32::WHITE), + ) + .fill(Color32::from_rgb(180, 40, 40)) + .rounding(Rounding::same(8.0)); + if ui.add(cancel_btn).clicked() { + self.youtube_auth_code = None; + self.youtube_auth_pending = false; + } + } else if let Some(ref err) = self.youtube_auth_error { + ui.label(egui::RichText::new("âš ī¸").size(44.0)); + ui.add_space(8.0); + ui.label(egui::RichText::new("Fout bij Koppelen").size(24.0).strong().color(Color32::from_rgb(255, 100, 100))); + ui.add_space(8.0); + ui.label(egui::RichText::new(err).size(15.0).color(Color32::from_rgb(220, 220, 220))); + ui.add_space(16.0); + let retry_btn = egui::Button::new( + egui::RichText::new("🔄 Opnieuw Proberen (Druk OK / Select op Yatse)") + .size(16.0) + .strong() + .color(Color32::WHITE), + ) + .fill(Color32::from_rgb(0, 164, 220)) + .rounding(Rounding::same(8.0)); + if ui.add(retry_btn).clicked() { + self.start_youtube_auth(); + } + } else { + ui.label(egui::RichText::new("đŸ“ē").size(48.0)); + ui.add_space(8.0); + ui.label(egui::RichText::new("YouTube op je HTPC").size(26.0).strong().color(Color32::WHITE)); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Koppel je Google account om je YouTube abonnementen, nieuwste uploads en afspeellijsten direct op TV te bekijken.") + .size(15.0) + .color(Color32::from_rgb(170, 185, 205)), + ); + ui.add_space(20.0); + let (btn_fill, btn_stroke, text_color) = if is_content_focused { + (Color32::from_rgb(0, 184, 245), Stroke::new(2.0_f32, Color32::WHITE), Color32::BLACK) + } else { + (Color32::from_rgb(0, 140, 190), Stroke::NONE, Color32::WHITE) + }; + let auth_btn = egui::Button::new( + egui::RichText::new("🔐 Koppel YouTube Account (Druk OK / Select)") + .size(17.0) + .strong() + .color(text_color), + ) + .fill(btn_fill) + .stroke(btn_stroke) + .rounding(Rounding::same(10.0)); + if ui.add(auth_btn).clicked() { + self.start_youtube_auth(); + } + ui.add_space(14.0); + ui.label( + egui::RichText::new("💡 Druk op je Yatse afstandsbediening op OK of Select om het koppelen direct te starten.") + .size(13.0) + .color(Color32::from_rgb(130, 145, 165)), + ); + } + }); + }); + }); + } + fn search_youtube(&mut self, query: &str) { let q = query.trim().to_string(); if q.is_empty() { @@ -795,6 +933,7 @@ impl KioskApp { RemoteNavAction::ActivateSection(idx) => { self.sidebar_index = idx.min(SidebarSection::ALL.len() - 1); self.focus_area = FocusArea::Content; + self.youtube_header_focus = false; self.selected_row = 0; self.selected_col = 0; self.selected_grid_idx = 0; @@ -908,6 +1047,16 @@ impl KioskApp { self.playback_osd_open = false; return; } + RemoteNavAction::SendText(ref text) => { + if self.current_section() == SidebarSection::YouTube { + self.youtube_tab = YouTubeTab::Search; + self.youtube_query = text.clone(); + self.search_youtube(text); + self.selected_grid_idx = 0; + self.youtube_header_focus = false; + } + return; + } _ => {} } @@ -1004,6 +1153,7 @@ impl KioskApp { } RemoteNavAction::Right | RemoteNavAction::Select => { self.focus_area = FocusArea::Content; + self.youtube_header_focus = false; } RemoteNavAction::Back => {} _ => {} @@ -1112,6 +1262,153 @@ impl KioskApp { } _ => {} } + } else if self.current_section() == SidebarSection::YouTube && self.folder_stack.is_empty() { + if !self.youtube_is_logged_in { + match action { + RemoteNavAction::Select => { + if self.youtube_auth_code.is_none() { + self.start_youtube_auth(); + } else { + self.youtube_auth_code = None; + self.youtube_auth_pending = false; + } + } + RemoteNavAction::Back => { + if self.youtube_auth_code.is_some() { + self.youtube_auth_code = None; + self.youtube_auth_pending = false; + } else { + self.focus_area = FocusArea::Sidebar; + } + } + RemoteNavAction::Left => { + self.focus_area = FocusArea::Sidebar; + } + _ => {} + } + } else if self.youtube_header_focus { + let is_channel = self.youtube_channel_videos.is_some(); + let max_idx = if is_channel { 0 } else { 4 }; + match action { + RemoteNavAction::Left => { + if self.youtube_header_idx == 0 { + self.focus_area = FocusArea::Sidebar; + } else { + self.youtube_header_idx -= 1; + } + } + RemoteNavAction::Right => { + if self.youtube_header_idx < max_idx { + self.youtube_header_idx += 1; + } + } + RemoteNavAction::Down => { + self.youtube_header_focus = false; + self.selected_grid_idx = 0; + } + RemoteNavAction::Back => { + self.focus_area = FocusArea::Sidebar; + } + RemoteNavAction::Select => { + if is_channel { + self.youtube_channel_videos = None; + self.selected_grid_idx = 0; + self.youtube_header_focus = false; + } else { + match self.youtube_header_idx { + 0 => { + self.youtube_tab = YouTubeTab::Feed; + self.selected_grid_idx = 0; + if self.youtube_feed.is_empty() && self.youtube_is_logged_in { + self.refresh_youtube_data(); + } + } + 1 => { + self.youtube_tab = YouTubeTab::Subscriptions; + self.selected_grid_idx = 0; + if self.youtube_subscriptions.is_empty() && self.youtube_is_logged_in { + self.refresh_youtube_data(); + } + } + 2 => { + self.youtube_tab = YouTubeTab::Search; + self.selected_grid_idx = 0; + } + 3 => { + self.refresh_youtube_data(); + } + 4 => { + let yt = self.youtube_client.clone(); + tokio::spawn(async move { + yt.logout().await; + }); + self.youtube_is_logged_in = false; + self.youtube_feed.clear(); + self.youtube_subscriptions.clear(); + self.youtube_channel_videos = None; + self.youtube_header_focus = false; + } + _ => {} + } + } + } + _ => {} + } + } else { + let (_, _, items) = self.get_active_grid_items(); + let total = items.len(); + let cols = self.grid_cols.max(1); + + match action { + RemoteNavAction::Up => { + if self.selected_grid_idx < cols { + self.youtube_header_focus = true; + if self.youtube_channel_videos.is_some() { + self.youtube_header_idx = 0; + } else { + self.youtube_header_idx = match self.youtube_tab { + YouTubeTab::Feed => 0, + YouTubeTab::Subscriptions => 1, + YouTubeTab::Search => 2, + }; + } + } else { + self.selected_grid_idx -= cols; + } + } + RemoteNavAction::Down => { + if self.selected_grid_idx + cols < total { + self.selected_grid_idx += cols; + } else if total > 0 { + self.selected_grid_idx = total - 1; + } + } + RemoteNavAction::Left => { + if self.selected_grid_idx % cols == 0 { + self.focus_area = FocusArea::Sidebar; + } else if self.selected_grid_idx > 0 { + self.selected_grid_idx -= 1; + } + } + RemoteNavAction::Right => { + if self.selected_grid_idx + 1 < total { + self.selected_grid_idx += 1; + } + } + RemoteNavAction::Select => { + self.play_selected(); + } + RemoteNavAction::Back => { + if self.youtube_channel_videos.is_some() { + self.youtube_channel_videos = None; + self.selected_grid_idx = 0; + } else { + self.focus_area = FocusArea::Sidebar; + } + } + _ => {} + } + } } else { // Library Grid Section or Normal Folder View let (_, _, items) = self.get_active_grid_items(); @@ -2323,10 +2620,20 @@ impl eframe::App for KioskApp { // YouTube View Header & Controls if self.current_section() == SidebarSection::YouTube { + if !self.youtube_is_logged_in { + self.render_youtube_login_screen(ui); + return; + } + + let is_hdr = self.focus_area == FocusArea::Content && self.youtube_header_focus; let channel_title_opt = self.youtube_channel_videos.as_ref().map(|(t, _)| t.clone()); ui.horizontal(|ui| { if let Some(title) = channel_title_opt { - if ui.button("âŦ… Terug naar kanalen").clicked() { + let mut back_btn = egui::Button::new("âŦ… Terug naar kanalen"); + if is_hdr && self.youtube_header_idx == 0 { + back_btn = back_btn.stroke(Stroke::new(2.0_f32, Color32::from_rgb(0, 184, 245))); + } + if ui.add(back_btn).clicked() { self.youtube_channel_videos = None; self.selected_grid_idx = 0; } @@ -2337,51 +2644,68 @@ impl eframe::App for KioskApp { let is_subs = self.youtube_tab == YouTubeTab::Subscriptions; let is_search = self.youtube_tab == YouTubeTab::Search; - if ui.selectable_label(is_feed, "đŸ“ē Nieuwste Video's").clicked() { + let mut btn_feed = egui::Button::new( + egui::RichText::new("đŸ“ē Nieuwste Video's").strong().color(if is_feed { Color32::WHITE } else { Color32::LIGHT_GRAY }) + ); + if is_hdr && self.youtube_header_idx == 0 { + btn_feed = btn_feed.stroke(Stroke::new(2.0_f32, Color32::from_rgb(0, 184, 245))); + } + if ui.add(btn_feed).clicked() { self.youtube_tab = YouTubeTab::Feed; self.selected_grid_idx = 0; if self.youtube_feed.is_empty() && self.youtube_is_logged_in { self.refresh_youtube_data(); } } - if ui.selectable_label(is_subs, "🔔 Mijn Kanalen").clicked() { + + let mut btn_subs = egui::Button::new( + egui::RichText::new("🔔 Mijn Kanalen").strong().color(if is_subs { Color32::WHITE } else { Color32::LIGHT_GRAY }) + ); + if is_hdr && self.youtube_header_idx == 1 { + btn_subs = btn_subs.stroke(Stroke::new(2.0_f32, Color32::from_rgb(0, 184, 245))); + } + if ui.add(btn_subs).clicked() { self.youtube_tab = YouTubeTab::Subscriptions; self.selected_grid_idx = 0; if self.youtube_subscriptions.is_empty() && self.youtube_is_logged_in { self.refresh_youtube_data(); } } - if ui.selectable_label(is_search, "🔍 Zoeken / URL").clicked() { + + let mut btn_search = egui::Button::new( + egui::RichText::new("🔍 Zoeken / URL").strong().color(if is_search { Color32::WHITE } else { Color32::LIGHT_GRAY }) + ); + if is_hdr && self.youtube_header_idx == 2 { + btn_search = btn_search.stroke(Stroke::new(2.0_f32, Color32::from_rgb(0, 184, 245))); + } + if ui.add(btn_search).clicked() { self.youtube_tab = YouTubeTab::Search; self.selected_grid_idx = 0; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if self.youtube_is_logged_in { - if ui.button("đŸšĒ Uitloggen").clicked() { - let yt = self.youtube_client.clone(); - tokio::spawn(async move { - yt.logout().await; - }); - self.youtube_is_logged_in = false; - self.youtube_feed.clear(); - self.youtube_subscriptions.clear(); - self.youtube_channel_videos = None; - } - if ui.button("🔄 Verversen").clicked() { - self.refresh_youtube_data(); - } - } else if self.youtube_client.is_configured() { - if self.youtube_auth_code.is_none() { - if ui.button("🔐 Koppel YouTube Account").clicked() { - self.start_youtube_auth(); - } - } else { - if ui.button("❌ Annuleer koppelen").clicked() { - self.youtube_auth_code = None; - self.youtube_auth_pending = false; - } - } + let mut btn_logout = egui::Button::new("đŸšĒ Uitloggen"); + if is_hdr && self.youtube_header_idx == 4 { + btn_logout = btn_logout.stroke(Stroke::new(2.0_f32, Color32::from_rgb(0, 184, 245))); + } + if ui.add(btn_logout).clicked() { + let yt = self.youtube_client.clone(); + tokio::spawn(async move { + yt.logout().await; + }); + self.youtube_is_logged_in = false; + self.youtube_feed.clear(); + self.youtube_subscriptions.clear(); + self.youtube_channel_videos = None; + self.youtube_header_focus = false; + } + + let mut btn_refresh = egui::Button::new("🔄 Verversen"); + if is_hdr && self.youtube_header_idx == 3 { + btn_refresh = btn_refresh.stroke(Stroke::new(2.0_f32, Color32::from_rgb(0, 184, 245))); + } + if ui.add(btn_refresh).clicked() { + self.refresh_youtube_data(); } }); } @@ -2402,70 +2726,6 @@ impl eframe::App for KioskApp { ui.add_space(8.0); } - // Auth error banner - if let Some(ref err) = self.youtube_auth_error { - ui.group(|ui| { - ui.vertical(|ui| { - ui.label( - egui::RichText::new(format!("âš ī¸ Fout bij koppelen: {}", err)) - .color(Color32::from_rgb(255, 100, 100)) - .size(15.0) - .strong(), - ); - }); - }); - ui.add_space(8.0); - } - - // Auth banner / instructions - if let Some(ref code_info) = self.youtube_auth_code { - ui.group(|ui| { - ui.vertical(|ui| { - ui.heading("📱 YouTube Account Koppelen"); - ui.add_space(4.0); - ui.label(egui::RichText::new("1. Ga op je telefoon of computer naar:").size(15.0)); - ui.label( - egui::RichText::new(&code_info.verification_url) - .color(Color32::from_rgb(0, 190, 250)) - .size(16.0) - .strong(), - ); - ui.add_space(6.0); - ui.label(egui::RichText::new("2. Voer deze koppelcode in:").size(15.0)); - ui.label( - egui::RichText::new(&code_info.user_code) - .color(Color32::from_rgb(255, 215, 0)) - .size(28.0) - .strong(), - ); - ui.add_space(4.0); - ui.label( - egui::RichText::new("âŗ Wachten op autorisatie in je browser... Zodra je toestemming geeft, laadt de kiosk direct je abonnementen!") - .italics() - .color(Color32::LIGHT_GRAY), - ); - }); - }); - ui.add_space(10.0); - } else if !self.youtube_client.is_configured() { - ui.group(|ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("â„šī¸ Om in te loggen op YouTube, vul 'client_id' in onder 'youtube:' in config.yaml.") - .color(Color32::from_rgb(170, 185, 205)), - ); - }); - }); - ui.add_space(8.0); - } else if !self.youtube_is_logged_in { - ui.group(|ui| { - ui.horizontal(|ui| { - ui.label("Nog niet ingelogd op YouTube. Klik hierboven op '🔐 Koppel YouTube Account' om te verbinden."); - }); - }); - ui.add_space(8.0); - } - if self.youtube_loading { ui.horizontal(|ui| { ui.spinner(); diff --git a/jellyfin-kiosk-rs/src/jellyfin.rs b/jellyfin-kiosk-rs/src/jellyfin.rs index ba6991f..f155852 100644 --- a/jellyfin-kiosk-rs/src/jellyfin.rs +++ b/jellyfin-kiosk-rs/src/jellyfin.rs @@ -36,6 +36,8 @@ pub struct MediaItem { pub artist_name: Option, pub genre: Option, pub index_number: Option, + pub parent_index_number: Option, + pub season_name: Option, pub year: Option, pub overview: Option, pub rating: Option, @@ -197,6 +199,7 @@ pub struct JellyfinClient { client: reqwest::Client, pub token: Arc>>, pub user_id: Arc>>, + pub library: Arc>, } impl JellyfinClient { @@ -206,6 +209,7 @@ impl JellyfinClient { client: reqwest::Client::new(), token: Arc::new(RwLock::new(None)), user_id: Arc::new(RwLock::new(None)), + library: Arc::new(RwLock::new(MediaLibrary::default())), } } @@ -256,6 +260,17 @@ impl JellyfinClient { Ok(()) } + pub async fn ensure_authenticated(&self) -> Result<(), Box> { + if self.token.read().await.is_none() { + self.authenticate().await?; + } + Ok(()) + } + + pub async fn get_token(&self) -> Option { + self.token.read().await.clone() + } + async fn get_authed_headers(&self) -> Result> { let token_guard = self.token.read().await; let token = token_guard.as_deref().ok_or("Not authenticated")?; @@ -553,7 +568,7 @@ impl JellyfinClient { let mut favorites = fetch_endpoint(&self.client, &url_fav, &headers).await; sort_media_items_alphabetically(&mut favorites); - Ok(MediaLibrary { + let lib = MediaLibrary { resume_items, latest_movies, latest_shows, @@ -566,7 +581,35 @@ impl JellyfinClient { photos, live_channels, favorites, - }) + }; + *self.library.write().await = lib.clone(); + Ok(lib) + } + + pub async fn get_library(&self) -> MediaLibrary { + { + let guard = self.library.read().await; + if !guard.all_movies.is_empty() || !guard.all_shows.is_empty() { + return guard.clone(); + } + } + if let Ok(lib) = self.fetch_all_media().await { + return lib; + } + self.library.read().await.clone() + } + + pub async fn fetch_show_episodes(&self, show_id: &str) -> 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 = format!( + "{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres", + base_url, show_id, user_id + ); + return fetch_endpoint(&self.client, &url, &headers).await; + } + Vec::new() } } @@ -616,6 +659,8 @@ fn parse_items(json: &serde_json::Value) -> Vec { let sort_name = it["SortName"].as_str().map(|s| s.to_string()); 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()); if !id.is_empty() { out.push(MediaItem { @@ -627,6 +672,8 @@ fn parse_items(json: &serde_json::Value) -> Vec { artist_name, genre, index_number, + parent_index_number, + season_name, year, overview, rating, diff --git a/jellyfin-kiosk-rs/src/main.rs b/jellyfin-kiosk-rs/src/main.rs index e542d9d..b11514d 100644 --- a/jellyfin-kiosk-rs/src/main.rs +++ b/jellyfin-kiosk-rs/src/main.rs @@ -40,12 +40,16 @@ async fn main() -> Result<(), Box> { std::sync::Arc::new(std::sync::Mutex::new(None)); let nav_tx = crate::yatse::RemoteNavSender::new(nav_tx_raw, egui_ctx_holder.clone()); - // 4. Start Yatse Kodi JSON-RPC Server & EventServer + // 4. Initialize Jellyfin Client + let client = JellyfinClient::new(config.jellyfin.clone()); + + // 5. Start Yatse Kodi JSON-RPC Server & EventServer if config.kodi_rpc.enabled { - let yatse_state = YatseServerState { - player: player.clone(), - nav_tx: nav_tx.clone(), - }; + let yatse_state = YatseServerState::new( + player.clone(), + nav_tx.clone(), + client.clone(), + ); let app_router = create_yatse_router(yatse_state.clone()); let port = config.kodi_rpc.http_port; @@ -95,8 +99,6 @@ async fn main() -> Result<(), Box> { mqtt::MqttService::start(config.clone(), player.clone()); } - // 6. Initialize Jellyfin Client - let client = JellyfinClient::new(config.jellyfin.clone()); // 7. Launch Native GUI via eframe (runs on main thread) let native_options = eframe::NativeOptions { diff --git a/jellyfin-kiosk-rs/src/yatse.rs b/jellyfin-kiosk-rs/src/yatse.rs index 2201d2e..ea09663 100644 --- a/jellyfin-kiosk-rs/src/yatse.rs +++ b/jellyfin-kiosk-rs/src/yatse.rs @@ -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> for RemoteNavSender { } } +#[derive(Default, Clone)] +pub struct IdMap { + str_to_id: Arc>>, + id_to_str: Arc>>, + next_id: Arc>, +} + +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 { + let read = self.id_to_str.read().await; + read.get(&id).cloned() + } +} + #[derive(Clone)] pub struct YatseServerState { pub player: Arc, pub nav_tx: RemoteNavSender, + pub client: JellyfinClient, + pub id_map: IdMap, + pub episodes_cache: Arc>>>, +} + +impl YatseServerState { + pub fn new(player: Arc, 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 = 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); + } } diff --git a/jellyfin-kiosk-rs/src/youtube.rs b/jellyfin-kiosk-rs/src/youtube.rs index 503f20a..239faab 100644 --- a/jellyfin-kiosk-rs/src/youtube.rs +++ b/jellyfin-kiosk-rs/src/youtube.rs @@ -454,6 +454,8 @@ impl YouTubeClient { artist_name: None, genre: None, index_number: None, + parent_index_number: None, + season_name: None, year: None, overview: snip.description, rating: None, @@ -531,6 +533,8 @@ impl YouTubeClient { artist_name: None, genre: date_str, index_number: None, + parent_index_number: None, + season_name: None, year: None, overview: snip.description, rating: None, @@ -632,6 +636,8 @@ impl YouTubeClient { artist_name: None, genre: date_str, index_number: None, + parent_index_number: None, + season_name: None, year: None, overview: snip.description, rating: None,