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

This commit is contained in:
bart
2026-11-05 18:46:32 +01:00
parent 4f2cdbd817
commit 3a407f90f7
8 changed files with 1321 additions and 115 deletions
+1 -1
View File
@@ -1460,7 +1460,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jellyfin-kiosk-rs"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"axum",
"eframe",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellyfin-kiosk-rs"
version = "0.1.0"
version = "0.1.2"
edition = "2021"
[dependencies]
Binary file not shown.
+353 -93
View File
@@ -178,6 +178,9 @@ pub struct KioskApp {
youtube_search_results: Vec<MediaItem>,
youtube_loading: bool,
youtube_query: String,
youtube_header_focus: bool,
#[allow(dead_code)]
youtube_header_idx: usize,
nav_rx: UnboundedReceiver<RemoteNavAction>,
event_tx: UnboundedSender<AppEvent>,
@@ -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();
+49 -2
View File
@@ -36,6 +36,8 @@ pub struct MediaItem {
pub artist_name: Option<String>,
pub genre: Option<String>,
pub index_number: Option<u32>,
pub parent_index_number: Option<u32>,
pub season_name: Option<String>,
pub year: Option<u32>,
pub overview: Option<String>,
pub rating: Option<f32>,
@@ -197,6 +199,7 @@ pub struct JellyfinClient {
client: reqwest::Client,
pub token: Arc<RwLock<Option<String>>>,
pub user_id: Arc<RwLock<Option<String>>>,
pub library: Arc<RwLock<MediaLibrary>>,
}
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<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>> {
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<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 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<MediaItem> {
artist_name,
genre,
index_number,
parent_index_number,
season_name,
year,
overview,
rating,
+9 -7
View File
@@ -40,12 +40,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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 {
+902 -11
View File
@@ -6,12 +6,15 @@ use axum::{
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::RwLock;
use crate::jellyfin::{JellyfinClient, MediaItem};
use crate::player::{PlaybackState, PlayerController};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteNavAction {
Up,
Down,
@@ -29,6 +32,7 @@ pub enum RemoteNavAction {
VolumeDown,
PlayPause,
Stop,
SendText(String),
}
#[derive(Clone)]
@@ -62,10 +66,80 @@ impl From<UnboundedSender<RemoteNavAction>> for RemoteNavSender {
}
}
#[derive(Default, Clone)]
pub struct IdMap {
str_to_id: Arc<RwLock<HashMap<String, i64>>>,
id_to_str: Arc<RwLock<HashMap<i64, String>>>,
next_id: Arc<RwLock<i64>>,
}
impl IdMap {
pub async fn get_or_create(&self, str_id: &str) -> i64 {
{
let read = self.str_to_id.read().await;
if let Some(&id) = read.get(str_id) {
return id;
}
}
let mut write_str = self.str_to_id.write().await;
let mut write_id = self.id_to_str.write().await;
let mut next = self.next_id.write().await;
if let Some(&id) = write_str.get(str_id) {
return id;
}
if *next <= 0 {
*next = 1;
}
let id = *next;
*next += 1;
write_str.insert(str_id.to_string(), id);
write_id.insert(id, str_id.to_string());
id
}
pub async fn get_str(&self, id: i64) -> Option<String> {
let read = self.id_to_str.read().await;
read.get(&id).cloned()
}
}
#[derive(Clone)]
pub struct YatseServerState {
pub player: Arc<PlayerController>,
pub nav_tx: RemoteNavSender,
pub client: JellyfinClient,
pub id_map: IdMap,
pub episodes_cache: Arc<RwLock<HashMap<String, Vec<MediaItem>>>>,
}
impl YatseServerState {
pub fn new(player: Arc<PlayerController>, nav_tx: RemoteNavSender, client: JellyfinClient) -> Self {
Self {
player,
nav_tx,
client,
id_map: IdMap::default(),
episodes_cache: Arc::new(RwLock::new(HashMap::new())),
}
}
}
fn parse_limits(params: Option<&Value>, total: usize) -> (usize, usize) {
if let Some(p) = params {
let limits_obj = p.get("limits")
.or_else(|| p.as_array().and_then(|a| a.iter().find_map(|x| x.get("limits"))));
if let Some(limits) = limits_obj {
let start = limits.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let end_val = limits.get("end").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let start = start.min(total);
let end = if end_val == 0 { total } else { (start + end_val).min(total) };
return (start, end);
}
}
(0, total)
}
#[derive(Debug, Deserialize)]
@@ -546,16 +620,60 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
json!({"jsonrpc": "2.0", "result": {"speed": speed}, "id": id})
}
"Player.Open" => {
if let Some(params) = req.params {
if let Some(item) = params.get("item") {
if let Some(file) = item.get("file").and_then(|f| f.as_str()) {
let norm_url = normalize_playback_url(file);
tracing::info!("Player.Open requested file: '{}' -> normalized: '{}'", file, norm_url);
let p = state.player.clone();
tokio::spawn(async move {
if let Some(ref params) = req.params {
let item_opt = params.get("item")
.or_else(|| params.as_array().and_then(|a| a.iter().find_map(|x| x.get("item"))))
.or_else(|| params.as_array().and_then(|a| a.first()));
if let Some(item) = item_opt {
let movieid_opt = item.get("movieid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
let episodeid_opt = item.get("episodeid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
let file_opt = item.get("file").and_then(|f| f.as_str()).map(|s| s.to_string());
let p = state.player.clone();
let client = state.client.clone();
let id_map = state.id_map.clone();
let episodes_cache = state.episodes_cache.clone();
tokio::spawn(async move {
let _ = client.ensure_authenticated().await;
let base_url = client.config.server_url.trim_end_matches('/').to_string();
let token = client.get_token().await.unwrap_or_default();
if let Some(mid) = movieid_opt {
if let Some(jellyfin_id) = id_map.get_str(mid).await {
let stream_url = format!("{}/Videos/{}/stream?static=true&api_key={}", base_url, jellyfin_id, token);
let lib = client.get_library().await;
let title = lib.all_movies.iter().find(|m| m.id == jellyfin_id).map(|m| m.name.clone()).unwrap_or_else(|| "Film".into());
tracing::info!("Player.Open movieid={} (jellyfin={}) -> {}", mid, jellyfin_id, stream_url);
let _ = p.play(&stream_url, &title).await;
return;
}
} else if let Some(eid) = episodeid_opt {
if let Some(jellyfin_id) = id_map.get_str(eid).await {
let stream_url = format!("{}/Videos/{}/stream?static=true&api_key={}", base_url, jellyfin_id, token);
let ep_title = {
let cache = episodes_cache.read().await;
cache.values().flatten().find(|e| e.id == jellyfin_id).map(|e| e.name.clone()).unwrap_or_else(|| "Aflevering".into())
};
tracing::info!("Player.Open episodeid={} (jellyfin={}) -> {}", eid, jellyfin_id, stream_url);
let _ = p.play(&stream_url, &ep_title).await;
return;
}
} else if let Some(file) = file_opt {
if file.starts_with("jellyfin://movie/") || file.starts_with("jellyfin://episode/") || file.starts_with("jellyfin://video/") {
let item_id = file.rsplit('/').next().unwrap_or("");
let stream_url = format!("{}/Videos/{}/stream?static=true&api_key={}", base_url, item_id, token);
tracing::info!("Player.Open file='{}' -> {}", file, stream_url);
let _ = p.play(&stream_url, "Media from Yatse").await;
return;
}
let norm_url = normalize_playback_url(&file);
tracing::info!("Player.Open file: '{}' -> normalized: '{}'", file, norm_url);
let _ = p.play(&norm_url, "Media from Yatse").await;
});
}
}
});
}
}
json!({
@@ -592,6 +710,20 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"Input.ContextMenu" | "Input.Info" => {
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Input.SendText" => {
let mut text_opt = None;
if let Some(ref params) = req.params {
if let Some(t) = params.get("text").and_then(|x| x.as_str()) {
text_opt = Some(t.to_string());
} else if let Some(t) = params.as_array().and_then(|a| a.first()).and_then(|x| x.as_str()) {
text_opt = Some(t.to_string());
}
}
if let Some(text) = text_opt {
let _ = state.nav_tx.send(RemoteNavAction::SendText(text));
}
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Input.ExecuteAction" => {
let raw_action = req.params.as_ref()
.and_then(|p| p.get("action").or_else(|| p.as_array().and_then(|a| a.first())))
@@ -887,6 +1019,16 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
let sources = if media == "video" {
vec![
json!({
"file": "jellyfin://movies/",
"label": "Jellyfin Films",
"media": "video"
}),
json!({
"file": "jellyfin://shows/",
"label": "Jellyfin Series",
"media": "video"
}),
json!({
"file": "plugin://plugin.video.youtube/",
"label": "YouTube",
@@ -901,7 +1043,7 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"jsonrpc": "2.0",
"result": {
"sources": sources,
"limits": {"start": 0, "end": 1, "total": 1}
"limits": {"start": 0, "end": sources.len(), "total": sources.len()}
},
"id": id
})
@@ -916,6 +1058,546 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"id": id
})
}
"VideoLibrary.GetMovies" => {
let lib = state.client.get_library().await;
let base_url = &state.client.config.server_url;
let mut movies = Vec::new();
for m in &lib.all_movies {
let mid = state.id_map.get_or_create(&m.id).await;
let runtime = m.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0);
let genres: Vec<&str> = m.genre.as_deref().unwrap_or("").split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
let playcount = if m.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 };
movies.push(json!({
"movieid": mid,
"label": m.name,
"title": m.name,
"originaltitle": m.name,
"year": m.year.unwrap_or(0),
"rating": m.rating.unwrap_or(0.0),
"plot": m.overview.clone().unwrap_or_default(),
"runtime": runtime,
"playcount": playcount,
"genre": genres,
"file": format!("jellyfin://movie/{}", m.id),
"thumbnail": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": m.thumb_url(base_url),
},
"resume": {
"position": 0.0,
"total": runtime as f64
}
}));
}
let total = movies.len();
let (start, end) = parse_limits(req.params.as_ref(), total);
let paged = movies[start..end].to_vec();
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": start, "end": end, "total": total},
"movies": paged
},
"id": id
})
}
"VideoLibrary.GetMovieDetails" => {
let mid_opt = req.params.as_ref().and_then(|p| {
p.get("movieid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
});
let mut details = json!({});
if let Some(mid) = mid_opt {
if let Some(jellyfin_id) = state.id_map.get_str(mid).await {
let lib = state.client.get_library().await;
if let Some(m) = lib.all_movies.iter().find(|x| x.id == jellyfin_id) {
let base_url = &state.client.config.server_url;
let runtime = m.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0);
let genres: Vec<&str> = m.genre.as_deref().unwrap_or("").split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
let playcount = if m.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 };
details = json!({
"movieid": mid,
"label": m.name,
"title": m.name,
"originaltitle": m.name,
"year": m.year.unwrap_or(0),
"rating": m.rating.unwrap_or(0.0),
"plot": m.overview.clone().unwrap_or_default(),
"runtime": runtime,
"playcount": playcount,
"genre": genres,
"file": format!("jellyfin://movie/{}", m.id),
"thumbnail": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": m.thumb_url(base_url),
},
"resume": {
"position": 0.0,
"total": runtime as f64
}
});
}
}
}
json!({
"jsonrpc": "2.0",
"result": {
"moviedetails": details
},
"id": id
})
}
"VideoLibrary.GetRecentlyAddedMovies" => {
let lib = state.client.get_library().await;
let base_url = &state.client.config.server_url;
let mut movies = Vec::new();
for m in &lib.latest_movies {
let mid = state.id_map.get_or_create(&m.id).await;
let runtime = m.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0);
let genres: Vec<&str> = m.genre.as_deref().unwrap_or("").split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
let playcount = if m.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 };
movies.push(json!({
"movieid": mid,
"label": m.name,
"title": m.name,
"originaltitle": m.name,
"year": m.year.unwrap_or(0),
"rating": m.rating.unwrap_or(0.0),
"plot": m.overview.clone().unwrap_or_default(),
"runtime": runtime,
"playcount": playcount,
"genre": genres,
"file": format!("jellyfin://movie/{}", m.id),
"thumbnail": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": m.thumb_url(base_url),
}
}));
}
let total = movies.len();
let (start, end) = parse_limits(req.params.as_ref(), total);
let paged = movies[start..end].to_vec();
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": start, "end": end, "total": total},
"movies": paged
},
"id": id
})
}
"VideoLibrary.GetTVShows" => {
let lib = state.client.get_library().await;
let base_url = &state.client.config.server_url;
let mut shows = Vec::new();
for s in &lib.all_shows {
let tid = state.id_map.get_or_create(&s.id).await;
let genres: Vec<&str> = s.genre.as_deref().unwrap_or("").split(',').map(|g| g.trim()).filter(|g| !g.is_empty()).collect();
shows.push(json!({
"tvshowid": tid,
"label": s.name,
"title": s.name,
"originaltitle": s.name,
"year": s.year.unwrap_or(0),
"rating": s.rating.unwrap_or(0.0),
"plot": s.overview.clone().unwrap_or_default(),
"genre": genres,
"episode": 0,
"watchedepisodes": 0,
"file": format!("jellyfin://show/{}", s.id),
"thumbnail": s.thumb_url(base_url),
"fanart": s.backdrop_url(base_url),
"art": {
"poster": s.poster_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumb": s.thumb_url(base_url),
}
}));
}
let total = shows.len();
let (start, end) = parse_limits(req.params.as_ref(), total);
let paged = shows[start..end].to_vec();
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": start, "end": end, "total": total},
"tvshows": paged
},
"id": id
})
}
"VideoLibrary.GetTVShowDetails" => {
let tid_opt = req.params.as_ref().and_then(|p| {
p.get("tvshowid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
});
let mut details = json!({});
if let Some(tid) = tid_opt {
if let Some(jellyfin_id) = state.id_map.get_str(tid).await {
let lib = state.client.get_library().await;
if let Some(s) = lib.all_shows.iter().find(|x| x.id == jellyfin_id) {
let base_url = &state.client.config.server_url;
let genres: Vec<&str> = s.genre.as_deref().unwrap_or("").split(',').map(|g| g.trim()).filter(|g| !g.is_empty()).collect();
details = json!({
"tvshowid": tid,
"label": s.name,
"title": s.name,
"originaltitle": s.name,
"year": s.year.unwrap_or(0),
"rating": s.rating.unwrap_or(0.0),
"plot": s.overview.clone().unwrap_or_default(),
"genre": genres,
"episode": 0,
"watchedepisodes": 0,
"file": format!("jellyfin://show/{}", s.id),
"thumbnail": s.thumb_url(base_url),
"fanart": s.backdrop_url(base_url),
"art": {
"poster": s.poster_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumb": s.thumb_url(base_url),
}
});
}
}
}
json!({
"jsonrpc": "2.0",
"result": {
"tvshowdetails": details
},
"id": id
})
}
"VideoLibrary.GetSeasons" => {
let tid_opt = req.params.as_ref().and_then(|p| {
p.get("tvshowid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
});
let mut seasons = Vec::new();
if let Some(tid) = tid_opt {
if let Some(jellyfin_id) = state.id_map.get_str(tid).await {
let episodes = {
let cache = state.episodes_cache.read().await;
cache.get(&jellyfin_id).cloned()
};
let eps = match episodes {
Some(e) => e,
None => {
let fetched = state.client.fetch_show_episodes(&jellyfin_id).await;
state.episodes_cache.write().await.insert(jellyfin_id.clone(), fetched.clone());
fetched
}
};
let mut season_map: std::collections::BTreeMap<u32, usize> = std::collections::BTreeMap::new();
for ep in &eps {
let s_num = ep.parent_index_number.unwrap_or(1);
*season_map.entry(s_num).or_insert(0) += 1;
}
let base_url = &state.client.config.server_url;
let lib = state.client.get_library().await;
let show = lib.all_shows.iter().find(|x| x.id == jellyfin_id);
let poster = show.map(|s| s.poster_url(base_url)).unwrap_or_default();
let backdrop = show.map(|s| s.backdrop_url(base_url)).unwrap_or_default();
for (s_num, ep_count) in season_map {
let sid = state.id_map.get_or_create(&format!("{}:season:{}", jellyfin_id, s_num)).await;
seasons.push(json!({
"seasonid": sid,
"tvshowid": tid,
"season": s_num,
"label": format!("Seizoen {}", s_num),
"episode": ep_count,
"watchedepisodes": 0,
"thumbnail": poster,
"fanart": backdrop,
"art": {
"poster": poster,
"fanart": backdrop,
"thumb": poster
}
}));
}
}
}
let total = seasons.len();
let (start, end) = parse_limits(req.params.as_ref(), total);
let paged = seasons[start..end].to_vec();
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": start, "end": end, "total": total},
"seasons": paged
},
"id": id
})
}
"VideoLibrary.GetSeasonDetails" => {
json!({
"jsonrpc": "2.0",
"result": {
"seasondetails": {}
},
"id": id
})
}
"VideoLibrary.GetEpisodes" => {
let tid_opt = req.params.as_ref().and_then(|p| {
p.get("tvshowid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
});
let season_filter = req.params.as_ref().and_then(|p| {
p.get("season").and_then(|v| v.as_u64().map(|n| n as u32))
});
let mut episodes_out = Vec::new();
if let Some(tid) = tid_opt {
if let Some(jellyfin_id) = state.id_map.get_str(tid).await {
let episodes = {
let cache = state.episodes_cache.read().await;
cache.get(&jellyfin_id).cloned()
};
let eps = match episodes {
Some(e) => e,
None => {
let fetched = state.client.fetch_show_episodes(&jellyfin_id).await;
state.episodes_cache.write().await.insert(jellyfin_id.clone(), fetched.clone());
fetched
}
};
let base_url = &state.client.config.server_url;
for ep in &eps {
let s_num = ep.parent_index_number.unwrap_or(1);
if let Some(sf) = season_filter {
if s_num != sf {
continue;
}
}
let ep_num = ep.index_number.unwrap_or(1);
let epid = state.id_map.get_or_create(&ep.id).await;
let runtime = ep.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0);
let playcount = if ep.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 };
episodes_out.push(json!({
"episodeid": epid,
"tvshowid": tid,
"season": s_num,
"episode": ep_num,
"label": format!("{}x{:02}. {}", s_num, ep_num, ep.name),
"title": ep.name,
"plot": ep.overview.clone().unwrap_or_default(),
"runtime": runtime,
"rating": ep.rating.unwrap_or(0.0),
"playcount": playcount,
"file": format!("jellyfin://episode/{}", ep.id),
"thumbnail": ep.thumb_url(base_url),
"fanart": ep.backdrop_url(base_url),
"art": {
"thumb": ep.thumb_url(base_url),
"poster": ep.poster_url(base_url),
"fanart": ep.backdrop_url(base_url),
}
}));
}
}
}
let total = episodes_out.len();
let (start, end) = parse_limits(req.params.as_ref(), total);
let paged = episodes_out[start..end].to_vec();
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": start, "end": end, "total": total},
"episodes": paged
},
"id": id
})
}
"VideoLibrary.GetEpisodeDetails" => {
let eid_opt = req.params.as_ref().and_then(|p| {
p.get("episodeid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
});
let mut details = json!({});
if let Some(eid) = eid_opt {
if let Some(jellyfin_id) = state.id_map.get_str(eid).await {
let cache = state.episodes_cache.read().await;
if let Some(ep) = cache.values().flatten().find(|e| e.id == jellyfin_id) {
let base_url = &state.client.config.server_url;
let s_num = ep.parent_index_number.unwrap_or(1);
let ep_num = ep.index_number.unwrap_or(1);
let runtime = ep.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0);
let playcount = if ep.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 };
details = json!({
"episodeid": eid,
"season": s_num,
"episode": ep_num,
"label": format!("{}x{:02}. {}", s_num, ep_num, ep.name),
"title": ep.name,
"plot": ep.overview.clone().unwrap_or_default(),
"runtime": runtime,
"rating": ep.rating.unwrap_or(0.0),
"playcount": playcount,
"file": format!("jellyfin://episode/{}", ep.id),
"thumbnail": ep.thumb_url(base_url),
"fanart": ep.backdrop_url(base_url),
"art": {
"thumb": ep.thumb_url(base_url),
"poster": ep.poster_url(base_url),
"fanart": ep.backdrop_url(base_url),
}
});
}
}
}
json!({
"jsonrpc": "2.0",
"result": {
"episodedetails": details
},
"id": id
})
}
"VideoLibrary.GetRecentlyAddedEpisodes" => {
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": 0, "end": 0, "total": 0},
"episodes": []
},
"id": id
})
}
"VideoLibrary.GetGenres" | "AudioLibrary.GetGenres" => {
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": 0, "end": 0, "total": 0},
"genres": []
},
"id": id
})
}
"AudioLibrary.GetArtists" => {
let lib = state.client.get_library().await;
let base_url = &state.client.config.server_url;
let mut artists = Vec::new();
for a in &lib.music_artists {
let aid = state.id_map.get_or_create(&a.id).await;
artists.push(json!({
"artistid": aid,
"artist": a.name,
"label": a.name,
"thumbnail": a.square_url(base_url),
"fanart": a.backdrop_url(base_url),
"art": {
"thumb": a.square_url(base_url),
"fanart": a.backdrop_url(base_url),
}
}));
}
let total = artists.len();
let (start, end) = parse_limits(req.params.as_ref(), total);
let paged = artists[start..end].to_vec();
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": start, "end": end, "total": total},
"artists": paged
},
"id": id
})
}
"AudioLibrary.GetAlbums" => {
let lib = state.client.get_library().await;
let base_url = &state.client.config.server_url;
let mut albums = Vec::new();
for al in &lib.music_albums {
let al_id = state.id_map.get_or_create(&al.id).await;
albums.push(json!({
"albumid": al_id,
"title": al.name,
"label": al.name,
"artist": al.artist_name.as_deref().unwrap_or(""),
"year": al.year.unwrap_or(0),
"thumbnail": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"art": {
"thumb": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
}
}));
}
let total = albums.len();
let (start, end) = parse_limits(req.params.as_ref(), total);
let paged = albums[start..end].to_vec();
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": start, "end": end, "total": total},
"albums": paged
},
"id": id
})
}
"AudioLibrary.GetRecentlyAddedAlbums" => {
let lib = state.client.get_library().await;
let base_url = &state.client.config.server_url;
let mut albums = Vec::new();
for al in &lib.latest_music {
let al_id = state.id_map.get_or_create(&al.id).await;
albums.push(json!({
"albumid": al_id,
"title": al.name,
"label": al.name,
"artist": al.artist_name.as_deref().unwrap_or(""),
"year": al.year.unwrap_or(0),
"thumbnail": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"art": {
"thumb": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
}
}));
}
let total = albums.len();
let (start, end) = parse_limits(req.params.as_ref(), total);
let paged = albums[start..end].to_vec();
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": start, "end": end, "total": total},
"albums": paged
},
"id": id
})
}
"AudioLibrary.GetSongs" => {
json!({
"jsonrpc": "2.0",
"result": {
"limits": {"start": 0, "end": 0, "total": 0},
"songs": []
},
"id": id
})
}
_ => {
json!({
"jsonrpc": "2.0",
@@ -963,4 +1645,213 @@ mod tests {
"https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"
);
}
#[tokio::test]
async fn test_video_library_get_movies_and_details() {
let config = crate::config::PlayerConfig::default();
let player = PlayerController::new(config);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let nav_tx = RemoteNavSender::from(tx);
let jf_config = crate::config::JellyfinConfig {
server_url: "http://localhost:8096".into(),
username: "test".into(),
password: None,
client_name: "test".into(),
device_name: "test".into(),
device_id: "test".into(),
};
let client = JellyfinClient::new(jf_config);
{
let mut lib = client.library.write().await;
lib.all_movies.push(MediaItem {
id: "test-movie-uuid-1".into(),
name: "The Matrix".into(),
sort_name: Some("Matrix".into()),
item_type: "Movie".into(),
series_name: None,
artist_name: None,
genre: Some("Sci-Fi, Action".into()),
index_number: None,
parent_index_number: None,
season_name: None,
year: Some(1999),
overview: Some("Neo takes the red pill".into()),
rating: Some(8.7),
runtime_ticks: Some(81600000000),
played_percentage: Some(0.0),
is_folder: false,
has_primary_image: true,
has_backdrop_image: true,
parent_primary_image_item_id: None,
parent_backdrop_item_id: None,
});
}
let state = YatseServerState::new(player, nav_tx, client);
let req = JsonRpcRequest {
jsonrpc: Some("2.0".into()),
method: "VideoLibrary.GetMovies".into(),
params: Some(json!({"limits": {"start": 0, "end": 10}})),
id: Some(json!(42)),
};
let res = dispatch_rpc(&state, req).await;
assert_eq!(res["id"], 42);
let movies = res["result"]["movies"].as_array().expect("movies array");
assert_eq!(movies.len(), 1);
assert_eq!(movies[0]["title"], "The Matrix");
assert_eq!(movies[0]["year"], 1999);
let mid = movies[0]["movieid"].as_i64().expect("movieid");
assert!(mid > 0);
// Test GetMovieDetails
let req_details = JsonRpcRequest {
jsonrpc: Some("2.0".into()),
method: "VideoLibrary.GetMovieDetails".into(),
params: Some(json!({"movieid": mid})),
id: Some(json!(43)),
};
let res_details = dispatch_rpc(&state, req_details).await;
assert_eq!(res_details["result"]["moviedetails"]["title"], "The Matrix");
assert_eq!(res_details["result"]["moviedetails"]["movieid"], mid);
}
#[tokio::test]
async fn test_video_library_tv_shows_seasons_episodes() {
let config = crate::config::PlayerConfig::default();
let player = PlayerController::new(config);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let nav_tx = RemoteNavSender::from(tx);
let jf_config = crate::config::JellyfinConfig {
server_url: "http://localhost:8096".into(),
username: "test".into(),
password: None,
client_name: "test".into(),
device_name: "test".into(),
device_id: "test".into(),
};
let client = JellyfinClient::new(jf_config);
let show_id = "show-uuid-1";
{
let mut lib = client.library.write().await;
lib.all_shows.push(MediaItem {
id: show_id.into(),
name: "Breaking Bad".into(),
sort_name: Some("Breaking Bad".into()),
item_type: "Series".into(),
series_name: None,
artist_name: None,
genre: Some("Drama, Crime".into()),
index_number: None,
parent_index_number: None,
season_name: None,
year: Some(2008),
overview: Some("Chemistry teacher cooks meth".into()),
rating: Some(9.5),
runtime_ticks: None,
played_percentage: None,
is_folder: true,
has_primary_image: true,
has_backdrop_image: true,
parent_primary_image_item_id: None,
parent_backdrop_item_id: None,
});
}
let state = YatseServerState::new(player, nav_tx, client);
{
let mut cache = state.episodes_cache.write().await;
cache.insert(show_id.into(), vec![
MediaItem {
id: "ep-uuid-1".into(),
name: "Pilot".into(),
sort_name: None,
item_type: "Episode".into(),
series_name: Some("Breaking Bad".into()),
artist_name: None,
genre: None,
index_number: Some(1),
parent_index_number: Some(1),
season_name: Some("Season 1".into()),
year: Some(2008),
overview: Some("Walter White diagnosed".into()),
rating: Some(9.0),
runtime_ticks: Some(34800000000),
played_percentage: None,
is_folder: false,
has_primary_image: true,
has_backdrop_image: false,
parent_primary_image_item_id: None,
parent_backdrop_item_id: None,
},
MediaItem {
id: "ep-uuid-2".into(),
name: "Cat's in the Bag...".into(),
sort_name: None,
item_type: "Episode".into(),
series_name: Some("Breaking Bad".into()),
artist_name: None,
genre: None,
index_number: Some(2),
parent_index_number: Some(1),
season_name: Some("Season 1".into()),
year: Some(2008),
overview: Some("Disposing of Krazy-8".into()),
rating: Some(8.7),
runtime_ticks: Some(28800000000),
played_percentage: None,
is_folder: false,
has_primary_image: true,
has_backdrop_image: false,
parent_primary_image_item_id: None,
parent_backdrop_item_id: None,
},
]);
}
// 1. GetTVShows
let req_shows = JsonRpcRequest {
jsonrpc: Some("2.0".into()),
method: "VideoLibrary.GetTVShows".into(),
params: None,
id: Some(json!(1)),
};
let res_shows = dispatch_rpc(&state, req_shows).await;
let shows = res_shows["result"]["tvshows"].as_array().expect("shows");
assert_eq!(shows.len(), 1);
assert_eq!(shows[0]["title"], "Breaking Bad");
let tid = shows[0]["tvshowid"].as_i64().expect("tid");
// 2. GetSeasons
let req_seasons = JsonRpcRequest {
jsonrpc: Some("2.0".into()),
method: "VideoLibrary.GetSeasons".into(),
params: Some(json!({"tvshowid": tid})),
id: Some(json!(2)),
};
let res_seasons = dispatch_rpc(&state, req_seasons).await;
let seasons = res_seasons["result"]["seasons"].as_array().expect("seasons");
assert_eq!(seasons.len(), 1);
assert_eq!(seasons[0]["season"], 1);
assert_eq!(seasons[0]["episode"], 2);
// 3. GetEpisodes
let req_eps = JsonRpcRequest {
jsonrpc: Some("2.0".into()),
method: "VideoLibrary.GetEpisodes".into(),
params: Some(json!({"tvshowid": tid, "season": 1})),
id: Some(json!(3)),
};
let res_eps = dispatch_rpc(&state, req_eps).await;
let eps = res_eps["result"]["episodes"].as_array().expect("episodes");
assert_eq!(eps.len(), 2);
assert_eq!(eps[0]["title"], "Pilot");
assert_eq!(eps[0]["episode"], 1);
assert_eq!(eps[1]["title"], "Cat's in the Bag...");
assert_eq!(eps[1]["episode"], 2);
}
}
+6
View File
@@ -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,