feat(kiosk): bump v0.1.2 with video library support for movies, tv shows, and seasons
This commit is contained in:
Generated
+1
-1
@@ -1460,7 +1460,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellyfin-kiosk-rs"
|
name = "jellyfin-kiosk-rs"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"eframe",
|
"eframe",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellyfin-kiosk-rs"
|
name = "jellyfin-kiosk-rs"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
Binary file not shown.
+343
-83
@@ -178,6 +178,9 @@ pub struct KioskApp {
|
|||||||
youtube_search_results: Vec<MediaItem>,
|
youtube_search_results: Vec<MediaItem>,
|
||||||
youtube_loading: bool,
|
youtube_loading: bool,
|
||||||
youtube_query: String,
|
youtube_query: String,
|
||||||
|
youtube_header_focus: bool,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
youtube_header_idx: usize,
|
||||||
|
|
||||||
nav_rx: UnboundedReceiver<RemoteNavAction>,
|
nav_rx: UnboundedReceiver<RemoteNavAction>,
|
||||||
event_tx: UnboundedSender<AppEvent>,
|
event_tx: UnboundedSender<AppEvent>,
|
||||||
@@ -264,6 +267,8 @@ impl KioskApp {
|
|||||||
youtube_search_results: Vec::new(),
|
youtube_search_results: Vec::new(),
|
||||||
youtube_loading: false,
|
youtube_loading: false,
|
||||||
youtube_query: String::new(),
|
youtube_query: String::new(),
|
||||||
|
youtube_header_focus: false,
|
||||||
|
youtube_header_idx: 0,
|
||||||
nav_rx,
|
nav_rx,
|
||||||
event_tx,
|
event_tx,
|
||||||
event_rx,
|
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) {
|
fn search_youtube(&mut self, query: &str) {
|
||||||
let q = query.trim().to_string();
|
let q = query.trim().to_string();
|
||||||
if q.is_empty() {
|
if q.is_empty() {
|
||||||
@@ -795,6 +933,7 @@ impl KioskApp {
|
|||||||
RemoteNavAction::ActivateSection(idx) => {
|
RemoteNavAction::ActivateSection(idx) => {
|
||||||
self.sidebar_index = idx.min(SidebarSection::ALL.len() - 1);
|
self.sidebar_index = idx.min(SidebarSection::ALL.len() - 1);
|
||||||
self.focus_area = FocusArea::Content;
|
self.focus_area = FocusArea::Content;
|
||||||
|
self.youtube_header_focus = false;
|
||||||
self.selected_row = 0;
|
self.selected_row = 0;
|
||||||
self.selected_col = 0;
|
self.selected_col = 0;
|
||||||
self.selected_grid_idx = 0;
|
self.selected_grid_idx = 0;
|
||||||
@@ -908,6 +1047,16 @@ impl KioskApp {
|
|||||||
self.playback_osd_open = false;
|
self.playback_osd_open = false;
|
||||||
return;
|
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 => {
|
RemoteNavAction::Right | RemoteNavAction::Select => {
|
||||||
self.focus_area = FocusArea::Content;
|
self.focus_area = FocusArea::Content;
|
||||||
|
self.youtube_header_focus = false;
|
||||||
}
|
}
|
||||||
RemoteNavAction::Back => {}
|
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 {
|
} else {
|
||||||
// Library Grid Section or Normal Folder View
|
// Library Grid Section or Normal Folder View
|
||||||
let (_, _, items) = self.get_active_grid_items();
|
let (_, _, items) = self.get_active_grid_items();
|
||||||
@@ -2323,10 +2620,20 @@ impl eframe::App for KioskApp {
|
|||||||
|
|
||||||
// YouTube View Header & Controls
|
// YouTube View Header & Controls
|
||||||
if self.current_section() == SidebarSection::YouTube {
|
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());
|
let channel_title_opt = self.youtube_channel_videos.as_ref().map(|(t, _)| t.clone());
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if let Some(title) = channel_title_opt {
|
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.youtube_channel_videos = None;
|
||||||
self.selected_grid_idx = 0;
|
self.selected_grid_idx = 0;
|
||||||
}
|
}
|
||||||
@@ -2337,28 +2644,51 @@ impl eframe::App for KioskApp {
|
|||||||
let is_subs = self.youtube_tab == YouTubeTab::Subscriptions;
|
let is_subs = self.youtube_tab == YouTubeTab::Subscriptions;
|
||||||
let is_search = self.youtube_tab == YouTubeTab::Search;
|
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.youtube_tab = YouTubeTab::Feed;
|
||||||
self.selected_grid_idx = 0;
|
self.selected_grid_idx = 0;
|
||||||
if self.youtube_feed.is_empty() && self.youtube_is_logged_in {
|
if self.youtube_feed.is_empty() && self.youtube_is_logged_in {
|
||||||
self.refresh_youtube_data();
|
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.youtube_tab = YouTubeTab::Subscriptions;
|
||||||
self.selected_grid_idx = 0;
|
self.selected_grid_idx = 0;
|
||||||
if self.youtube_subscriptions.is_empty() && self.youtube_is_logged_in {
|
if self.youtube_subscriptions.is_empty() && self.youtube_is_logged_in {
|
||||||
self.refresh_youtube_data();
|
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.youtube_tab = YouTubeTab::Search;
|
||||||
self.selected_grid_idx = 0;
|
self.selected_grid_idx = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
if self.youtube_is_logged_in {
|
let mut btn_logout = egui::Button::new("🚪 Uitloggen");
|
||||||
if ui.button("🚪 Uitloggen").clicked() {
|
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();
|
let yt = self.youtube_client.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
yt.logout().await;
|
yt.logout().await;
|
||||||
@@ -2367,22 +2697,16 @@ impl eframe::App for KioskApp {
|
|||||||
self.youtube_feed.clear();
|
self.youtube_feed.clear();
|
||||||
self.youtube_subscriptions.clear();
|
self.youtube_subscriptions.clear();
|
||||||
self.youtube_channel_videos = None;
|
self.youtube_channel_videos = None;
|
||||||
|
self.youtube_header_focus = false;
|
||||||
}
|
}
|
||||||
if ui.button("🔄 Verversen").clicked() {
|
|
||||||
|
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();
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2402,70 +2726,6 @@ impl eframe::App for KioskApp {
|
|||||||
ui.add_space(8.0);
|
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 {
|
if self.youtube_loading {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.spinner();
|
ui.spinner();
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ pub struct MediaItem {
|
|||||||
pub artist_name: Option<String>,
|
pub artist_name: Option<String>,
|
||||||
pub genre: Option<String>,
|
pub genre: Option<String>,
|
||||||
pub index_number: Option<u32>,
|
pub index_number: Option<u32>,
|
||||||
|
pub parent_index_number: Option<u32>,
|
||||||
|
pub season_name: Option<String>,
|
||||||
pub year: Option<u32>,
|
pub year: Option<u32>,
|
||||||
pub overview: Option<String>,
|
pub overview: Option<String>,
|
||||||
pub rating: Option<f32>,
|
pub rating: Option<f32>,
|
||||||
@@ -197,6 +199,7 @@ pub struct JellyfinClient {
|
|||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
pub token: Arc<RwLock<Option<String>>>,
|
pub token: Arc<RwLock<Option<String>>>,
|
||||||
pub user_id: Arc<RwLock<Option<String>>>,
|
pub user_id: Arc<RwLock<Option<String>>>,
|
||||||
|
pub library: Arc<RwLock<MediaLibrary>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JellyfinClient {
|
impl JellyfinClient {
|
||||||
@@ -206,6 +209,7 @@ impl JellyfinClient {
|
|||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
token: Arc::new(RwLock::new(None)),
|
token: Arc::new(RwLock::new(None)),
|
||||||
user_id: 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_authenticated(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
if self.token.read().await.is_none() {
|
||||||
|
self.authenticate().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_token(&self) -> Option<String> {
|
||||||
|
self.token.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_authed_headers(&self) -> Result<HeaderMap, Box<dyn std::error::Error + Send + Sync>> {
|
async fn get_authed_headers(&self) -> Result<HeaderMap, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let token_guard = self.token.read().await;
|
let token_guard = self.token.read().await;
|
||||||
let token = token_guard.as_deref().ok_or("Not authenticated")?;
|
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;
|
let mut favorites = fetch_endpoint(&self.client, &url_fav, &headers).await;
|
||||||
sort_media_items_alphabetically(&mut favorites);
|
sort_media_items_alphabetically(&mut favorites);
|
||||||
|
|
||||||
Ok(MediaLibrary {
|
let lib = MediaLibrary {
|
||||||
resume_items,
|
resume_items,
|
||||||
latest_movies,
|
latest_movies,
|
||||||
latest_shows,
|
latest_shows,
|
||||||
@@ -566,7 +581,35 @@ impl JellyfinClient {
|
|||||||
photos,
|
photos,
|
||||||
live_channels,
|
live_channels,
|
||||||
favorites,
|
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<MediaItem> {
|
||||||
|
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<MediaItem> {
|
|||||||
|
|
||||||
let sort_name = it["SortName"].as_str().map(|s| s.to_string());
|
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 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() {
|
if !id.is_empty() {
|
||||||
out.push(MediaItem {
|
out.push(MediaItem {
|
||||||
@@ -627,6 +672,8 @@ fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
|||||||
artist_name,
|
artist_name,
|
||||||
genre,
|
genre,
|
||||||
index_number,
|
index_number,
|
||||||
|
parent_index_number,
|
||||||
|
season_name,
|
||||||
year,
|
year,
|
||||||
overview,
|
overview,
|
||||||
rating,
|
rating,
|
||||||
|
|||||||
@@ -40,12 +40,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||||||
std::sync::Arc::new(std::sync::Mutex::new(None));
|
std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||||
let nav_tx = crate::yatse::RemoteNavSender::new(nav_tx_raw, egui_ctx_holder.clone());
|
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 {
|
if config.kodi_rpc.enabled {
|
||||||
let yatse_state = YatseServerState {
|
let yatse_state = YatseServerState::new(
|
||||||
player: player.clone(),
|
player.clone(),
|
||||||
nav_tx: nav_tx.clone(),
|
nav_tx.clone(),
|
||||||
};
|
client.clone(),
|
||||||
|
);
|
||||||
let app_router = create_yatse_router(yatse_state.clone());
|
let app_router = create_yatse_router(yatse_state.clone());
|
||||||
let port = config.kodi_rpc.http_port;
|
let port = config.kodi_rpc.http_port;
|
||||||
|
|
||||||
@@ -95,8 +99,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||||||
mqtt::MqttService::start(config.clone(), player.clone());
|
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)
|
// 7. Launch Native GUI via eframe (runs on main thread)
|
||||||
let native_options = eframe::NativeOptions {
|
let native_options = eframe::NativeOptions {
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::jellyfin::{JellyfinClient, MediaItem};
|
||||||
use crate::player::{PlaybackState, PlayerController};
|
use crate::player::{PlaybackState, PlayerController};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum RemoteNavAction {
|
pub enum RemoteNavAction {
|
||||||
Up,
|
Up,
|
||||||
Down,
|
Down,
|
||||||
@@ -29,6 +32,7 @@ pub enum RemoteNavAction {
|
|||||||
VolumeDown,
|
VolumeDown,
|
||||||
PlayPause,
|
PlayPause,
|
||||||
Stop,
|
Stop,
|
||||||
|
SendText(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[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)]
|
#[derive(Clone)]
|
||||||
pub struct YatseServerState {
|
pub struct YatseServerState {
|
||||||
pub player: Arc<PlayerController>,
|
pub player: Arc<PlayerController>,
|
||||||
pub nav_tx: RemoteNavSender,
|
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)]
|
#[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})
|
json!({"jsonrpc": "2.0", "result": {"speed": speed}, "id": id})
|
||||||
}
|
}
|
||||||
"Player.Open" => {
|
"Player.Open" => {
|
||||||
if let Some(params) = req.params {
|
if let Some(ref params) = req.params {
|
||||||
if let Some(item) = params.get("item") {
|
let item_opt = params.get("item")
|
||||||
if let Some(file) = item.get("file").and_then(|f| f.as_str()) {
|
.or_else(|| params.as_array().and_then(|a| a.iter().find_map(|x| x.get("item"))))
|
||||||
let norm_url = normalize_playback_url(file);
|
.or_else(|| params.as_array().and_then(|a| a.first()));
|
||||||
tracing::info!("Player.Open requested file: '{}' -> normalized: '{}'", file, norm_url);
|
|
||||||
|
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 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 {
|
tokio::spawn(async move {
|
||||||
let _ = p.play(&norm_url, "Media from Yatse").await;
|
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!({
|
json!({
|
||||||
@@ -592,6 +710,20 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
"Input.ContextMenu" | "Input.Info" => {
|
"Input.ContextMenu" | "Input.Info" => {
|
||||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
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" => {
|
"Input.ExecuteAction" => {
|
||||||
let raw_action = req.params.as_ref()
|
let raw_action = req.params.as_ref()
|
||||||
.and_then(|p| p.get("action").or_else(|| p.as_array().and_then(|a| a.first())))
|
.and_then(|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" {
|
let sources = if media == "video" {
|
||||||
vec![
|
vec![
|
||||||
|
json!({
|
||||||
|
"file": "jellyfin://movies/",
|
||||||
|
"label": "Jellyfin Films",
|
||||||
|
"media": "video"
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"file": "jellyfin://shows/",
|
||||||
|
"label": "Jellyfin Series",
|
||||||
|
"media": "video"
|
||||||
|
}),
|
||||||
json!({
|
json!({
|
||||||
"file": "plugin://plugin.video.youtube/",
|
"file": "plugin://plugin.video.youtube/",
|
||||||
"label": "YouTube",
|
"label": "YouTube",
|
||||||
@@ -901,7 +1043,7 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"result": {
|
"result": {
|
||||||
"sources": sources,
|
"sources": sources,
|
||||||
"limits": {"start": 0, "end": 1, "total": 1}
|
"limits": {"start": 0, "end": sources.len(), "total": sources.len()}
|
||||||
},
|
},
|
||||||
"id": id
|
"id": id
|
||||||
})
|
})
|
||||||
@@ -916,6 +1058,546 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
"id": id
|
"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!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -963,4 +1645,213 @@ mod tests {
|
|||||||
"https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"
|
"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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,6 +454,8 @@ impl YouTubeClient {
|
|||||||
artist_name: None,
|
artist_name: None,
|
||||||
genre: None,
|
genre: None,
|
||||||
index_number: None,
|
index_number: None,
|
||||||
|
parent_index_number: None,
|
||||||
|
season_name: None,
|
||||||
year: None,
|
year: None,
|
||||||
overview: snip.description,
|
overview: snip.description,
|
||||||
rating: None,
|
rating: None,
|
||||||
@@ -531,6 +533,8 @@ impl YouTubeClient {
|
|||||||
artist_name: None,
|
artist_name: None,
|
||||||
genre: date_str,
|
genre: date_str,
|
||||||
index_number: None,
|
index_number: None,
|
||||||
|
parent_index_number: None,
|
||||||
|
season_name: None,
|
||||||
year: None,
|
year: None,
|
||||||
overview: snip.description,
|
overview: snip.description,
|
||||||
rating: None,
|
rating: None,
|
||||||
@@ -632,6 +636,8 @@ impl YouTubeClient {
|
|||||||
artist_name: None,
|
artist_name: None,
|
||||||
genre: date_str,
|
genre: date_str,
|
||||||
index_number: None,
|
index_number: None,
|
||||||
|
parent_index_number: None,
|
||||||
|
season_name: None,
|
||||||
year: None,
|
year: None,
|
||||||
overview: snip.description,
|
overview: snip.description,
|
||||||
rating: None,
|
rating: None,
|
||||||
|
|||||||
Reference in New Issue
Block a user