Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
423bc75c94 |
Generated
+1
-1
@@ -1460,7 +1460,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jellyfin-kiosk-rs"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"eframe",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellyfin-kiosk-rs"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
[2m2026-11-05T21:13:59.466559Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs[0m[2m:[0m Starting Jellyfin HTPC Kiosk (Rust)...
|
||||
[2m2026-11-05T21:13:59.467206Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::config[0m[2m:[0m Loaded config from ../config.yaml
|
||||
[2m2026-11-05T21:13:59.474938Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::player[0m[2m:[0m libmpv initialized successfully (vo=libmpv, hwdec=auto)
|
||||
[2m2026-11-05T21:13:59.549439Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::yatse[0m[2m:[0m Yatse Kodi TCP JSON-RPC socket server listening on 0.0.0.0:9090
|
||||
[2m2026-11-05T21:13:59.549444Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs[0m[2m:[0m Yatse Kodi JSON-RPC server listening on http://0.0.0.0:8080
|
||||
[2m2026-11-05T21:13:59.549923Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::eventserver[0m[2m:[0m Kodi EventServer listening on UDP 0.0.0.0:9777
|
||||
[2m2026-11-05T21:13:59.551471Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs[0m[2m:[0m Published Zeroconf services for Yatse discovery: 'Kodi (bart-laptop)'
|
||||
[2m2026-11-05T21:13:59.691500Z[0m [32m INFO[0m [2mwinit::platform_impl::linux::x11::window[0m[2m:[0m Guessed window scale factor: 1
|
||||
[2m2026-11-05T21:13:59.729454Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::youtube[0m[2m:[0m Loaded cached YouTube token from youtube_token.json
|
||||
Cannot load libcuda.so.1
|
||||
[2m2026-11-05T21:13:59.854273Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::player[0m[2m:[0m libmpv OpenGL render context created successfully!
|
||||
[2m2026-11-05T21:13:59.916581Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::jellyfin[0m[2m:[0m Authenticated successfully with Jellyfin as bart
|
||||
@@ -135,6 +135,7 @@ pub struct KioskApp {
|
||||
was_playing: bool,
|
||||
playback_osd_open: bool,
|
||||
playback_osd_idx: usize,
|
||||
playback_osd_until: Option<Instant>,
|
||||
last_seen_volume: f64,
|
||||
volume_hud_until: Option<Instant>,
|
||||
osd_notice: Option<(String, Instant)>,
|
||||
@@ -236,6 +237,7 @@ impl KioskApp {
|
||||
was_playing: false,
|
||||
playback_osd_open: false,
|
||||
playback_osd_idx: 0,
|
||||
playback_osd_until: None,
|
||||
last_seen_volume: 100.0,
|
||||
volume_hud_until: None,
|
||||
osd_notice: None,
|
||||
@@ -1070,19 +1072,24 @@ impl KioskApp {
|
||||
} else {
|
||||
self.playback_osd_idx - 1
|
||||
};
|
||||
self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4));
|
||||
}
|
||||
RemoteNavAction::Right => {
|
||||
self.playback_osd_idx = (self.playback_osd_idx + 1) % 10;
|
||||
self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4));
|
||||
}
|
||||
RemoteNavAction::Up | RemoteNavAction::Down => {
|
||||
// Kodi behavior: Up or Down closes the OSD control bar
|
||||
self.playback_osd_open = false;
|
||||
self.playback_osd_until = None;
|
||||
}
|
||||
RemoteNavAction::Select => {
|
||||
self.execute_osd_action(self.playback_osd_idx);
|
||||
self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4));
|
||||
}
|
||||
RemoteNavAction::Back => {
|
||||
self.playback_osd_open = false;
|
||||
self.playback_osd_until = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1109,13 +1116,16 @@ impl KioskApp {
|
||||
self.osd_notice = Some(("⏪ -60s".to_string(), Instant::now() + Duration::from_millis(1500)));
|
||||
}
|
||||
RemoteNavAction::Select => {
|
||||
// Single Select/OK opens the OSD and starts the auto-hide timer without pausing!
|
||||
self.playback_osd_open = true;
|
||||
self.playback_osd_idx = 2; // Pause/Play
|
||||
self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4));
|
||||
}
|
||||
RemoteNavAction::Back => {
|
||||
// Return to library browser while media keeps playing in background
|
||||
self.view_mode = ViewMode::Browser;
|
||||
self.playback_osd_open = false;
|
||||
self.playback_osd_until = None;
|
||||
let p = self.player.clone();
|
||||
tokio::spawn(async move {
|
||||
p.set_video_visible(false).await;
|
||||
@@ -1651,15 +1661,27 @@ impl eframe::App for KioskApp {
|
||||
self.view_mode = ViewMode::Player;
|
||||
self.playback_osd_open = true;
|
||||
self.playback_osd_idx = 2; // Pause/Play
|
||||
self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4));
|
||||
self.last_playing_title = current_title;
|
||||
} else if !is_playing && self.was_playing {
|
||||
self.view_mode = ViewMode::Browser;
|
||||
self.playback_osd_open = false;
|
||||
self.playback_osd_until = None;
|
||||
self.last_playing_title.clear();
|
||||
}
|
||||
self.was_playing = is_playing;
|
||||
|
||||
if is_playing || self.volume_hud_until.is_some() || self.osd_notice.is_some() || self.folder_loading {
|
||||
// Auto-hide playback OSD for video
|
||||
if self.playback_osd_open {
|
||||
if let Some(until) = self.playback_osd_until {
|
||||
if Instant::now() >= until {
|
||||
self.playback_osd_open = false;
|
||||
self.playback_osd_until = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_playing || self.volume_hud_until.is_some() || self.playback_osd_until.is_some() || self.osd_notice.is_some() || self.folder_loading {
|
||||
ctx.request_repaint_after(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ pub enum PlayableTarget {
|
||||
OpenFolder { folder_id: String, name: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct MediaItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -48,6 +48,11 @@ pub struct MediaItem {
|
||||
pub has_backdrop_image: bool,
|
||||
pub parent_primary_image_item_id: Option<String>,
|
||||
pub parent_backdrop_item_id: Option<String>,
|
||||
pub recursive_item_count: Option<u32>,
|
||||
pub child_count: Option<u32>,
|
||||
pub series_id: Option<String>,
|
||||
pub album_id: Option<String>,
|
||||
pub album: Option<String>,
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
@@ -604,22 +609,95 @@ impl JellyfinClient {
|
||||
if let Ok(headers) = self.get_authed_headers().await {
|
||||
let user_id = self.user_id.read().await.clone().unwrap_or_default();
|
||||
let url = format!(
|
||||
"{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres",
|
||||
"{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
|
||||
base_url, show_id, user_id
|
||||
);
|
||||
return fetch_endpoint(&self.client, &url, &headers).await;
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub async fn fetch_episodes_paged(
|
||||
&self,
|
||||
show_id: Option<&str>,
|
||||
season: Option<u32>,
|
||||
start: usize,
|
||||
limit: usize,
|
||||
) -> (usize, 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 = if let Some(sid) = show_id {
|
||||
if let Some(s_num) = season {
|
||||
format!(
|
||||
"{}/Shows/{}/Episodes?userId={}&season={}&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
|
||||
base_url, sid, user_id, s_num, start, limit
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}/Shows/{}/Episodes?userId={}&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
|
||||
base_url, sid, user_id, start, limit
|
||||
)
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"{}/Users/{}/Items?includeItemTypes=Episode&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
|
||||
base_url, user_id, start, limit
|
||||
)
|
||||
};
|
||||
return fetch_endpoint_paged(&self.client, &url, &headers).await;
|
||||
}
|
||||
(0, Vec::new())
|
||||
}
|
||||
|
||||
pub async fn fetch_songs_paged(
|
||||
&self,
|
||||
album_id: Option<&str>,
|
||||
artist_id: Option<&str>,
|
||||
start: usize,
|
||||
limit: usize,
|
||||
) -> (usize, Vec<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 = if let Some(aid) = album_id {
|
||||
format!(
|
||||
"{}/Users/{}/Items?parentId={}&includeItemTypes=Audio&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks",
|
||||
base_url, user_id, aid, start, limit
|
||||
)
|
||||
} else if let Some(arid) = artist_id {
|
||||
format!(
|
||||
"{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks",
|
||||
base_url, user_id, arid, start, limit
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}/Users/{}/Items?includeItemTypes=Audio&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks",
|
||||
base_url, user_id, start, limit
|
||||
)
|
||||
};
|
||||
return fetch_endpoint_paged(&self.client, &url, &headers).await;
|
||||
}
|
||||
(0, Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_endpoint(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> Vec<MediaItem> {
|
||||
fetch_endpoint_paged(client, url, headers).await.1
|
||||
}
|
||||
|
||||
async fn fetch_endpoint_paged(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> (usize, Vec<MediaItem>) {
|
||||
if let Ok(resp) = client.get(url).headers(headers.clone()).send().await {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
return parse_items(&json);
|
||||
let items = parse_items(&json);
|
||||
let total = json["TotalRecordCount"]
|
||||
.as_u64()
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(items.len());
|
||||
return (total, items);
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
(0, Vec::new())
|
||||
}
|
||||
|
||||
fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
||||
@@ -630,7 +708,16 @@ fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
||||
let name = it["Name"].as_str().unwrap_or("Onbekend").to_string();
|
||||
let item_type = it["Type"].as_str().unwrap_or("Video").to_string();
|
||||
let series_name = it["SeriesName"].as_str().map(|s| s.to_string());
|
||||
let artist_name = it["AlbumArtist"].as_str().or_else(|| it["Artist"].as_str()).map(|s| s.to_string());
|
||||
let artist_name = it["AlbumArtist"]
|
||||
.as_str()
|
||||
.or_else(|| it["Artist"].as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
it["Artists"].as_array().and_then(|arr| arr.first().and_then(|a| a.as_str())).map(|s| s.to_string())
|
||||
})
|
||||
.or_else(|| {
|
||||
it["ArtistItems"].as_array().and_then(|arr| arr.first().and_then(|a| a["Name"].as_str())).map(|s| s.to_string())
|
||||
});
|
||||
let genre = it["Genres"].as_array().and_then(|arr| {
|
||||
let list: Vec<&str> = arr.iter().filter_map(|g| g.as_str()).collect();
|
||||
if list.is_empty() {
|
||||
@@ -661,6 +748,11 @@ fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
||||
let index_number = it["IndexNumber"].as_u64().map(|n| n as u32);
|
||||
let parent_index_number = it["ParentIndexNumber"].as_u64().map(|n| n as u32);
|
||||
let season_name = it["SeasonName"].as_str().map(|s| s.to_string());
|
||||
let recursive_item_count = it["RecursiveItemCount"].as_u64().map(|n| n as u32);
|
||||
let child_count = it["ChildCount"].as_u64().map(|n| n as u32);
|
||||
let series_id = it["SeriesId"].as_str().map(|s| s.to_string());
|
||||
let album_id = it["AlbumId"].as_str().map(|s| s.to_string());
|
||||
let album = it["Album"].as_str().map(|s| s.to_string());
|
||||
|
||||
if !id.is_empty() {
|
||||
out.push(MediaItem {
|
||||
@@ -684,6 +776,11 @@ fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
||||
has_backdrop_image,
|
||||
parent_primary_image_item_id,
|
||||
parent_backdrop_item_id,
|
||||
recursive_item_count,
|
||||
child_count,
|
||||
series_id,
|
||||
album_id,
|
||||
album,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ impl PlayerController {
|
||||
"yt-dlp"
|
||||
};
|
||||
mpv.set_option_string("script-opts", &format!("ytdl_hook-ytdl_path={}", ytdlp_path));
|
||||
mpv.set_option_string("ytdl-format", "bestvideo[height<=?1080]+bestaudio/best");
|
||||
mpv.set_option_string("ytdl-format", "bestvideo[height<=?1080][vcodec!*='av01']+bestaudio/bestvideo[height<=?1080]+bestaudio/best");
|
||||
mpv.set_option_string("ytdl-raw-options", "no-playlist=");
|
||||
|
||||
let init_res = unsafe { libmpv_sys::mpv_initialize(raw_mpv) };
|
||||
|
||||
+280
-23
@@ -135,13 +135,35 @@ fn parse_limits(params: Option<&Value>, total: usize) -> (usize, usize) {
|
||||
let start = limits.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||
let end_val = limits.get("end").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||
let start = start.min(total);
|
||||
let end = if end_val == 0 { total } else { (start + end_val).min(total) };
|
||||
let end = if end_val == 0 || end_val <= start {
|
||||
total
|
||||
} else {
|
||||
end_val.min(total)
|
||||
};
|
||||
return (start, end);
|
||||
}
|
||||
}
|
||||
(0, total)
|
||||
}
|
||||
|
||||
fn parse_limits_raw(params: Option<&Value>, default_limit: usize) -> (usize, usize) {
|
||||
if let Some(p) = params {
|
||||
let limits_obj = p.get("limits")
|
||||
.or_else(|| p.as_array().and_then(|a| a.iter().find_map(|x| x.get("limits"))));
|
||||
if let Some(limits) = limits_obj {
|
||||
let start = limits.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||
let end_val = limits.get("end").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||
let limit = if end_val > start {
|
||||
end_val - start
|
||||
} else {
|
||||
default_limit
|
||||
};
|
||||
return (start, limit);
|
||||
}
|
||||
}
|
||||
(0, default_limit)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
#[allow(dead_code)]
|
||||
@@ -207,10 +229,30 @@ pub fn normalize_playback_url(file: &str) -> String {
|
||||
if let Some(query) = trimmed.split('?').nth(1) {
|
||||
let decoded = percent_decode(query);
|
||||
if decoded.starts_with("http://") || decoded.starts_with("https://") {
|
||||
return decoded;
|
||||
return normalize_playback_url(&decoded);
|
||||
}
|
||||
if let Some(url_part) = decoded.split("url=").nth(1) {
|
||||
return percent_decode(url_part);
|
||||
return normalize_playback_url(&percent_decode(url_part));
|
||||
}
|
||||
}
|
||||
} else if trimmed.contains("youtube.com/watch") || trimmed.contains("youtu.be/") || trimmed.contains("youtube.com/shorts/") {
|
||||
if let Some(pos) = trimmed.find("v=") {
|
||||
let rest = &trimmed[pos + 2..];
|
||||
let vid = rest.split('&').next().unwrap_or(rest);
|
||||
if !vid.is_empty() {
|
||||
return format!("https://www.youtube.com/watch?v={}", vid);
|
||||
}
|
||||
} else if let Some(pos) = trimmed.find("youtu.be/") {
|
||||
let rest = &trimmed[pos + 9..];
|
||||
let vid = rest.split('?').next().unwrap_or(rest);
|
||||
if !vid.is_empty() {
|
||||
return format!("https://www.youtube.com/watch?v={}", vid);
|
||||
}
|
||||
} else if let Some(pos) = trimmed.find("youtube.com/shorts/") {
|
||||
let rest = &trimmed[pos + 19..];
|
||||
let vid = rest.split('?').next().unwrap_or(rest);
|
||||
if !vid.is_empty() {
|
||||
return format!("https://www.youtube.com/watch?v={}", vid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,6 +722,7 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
||||
if let Some(item) = item_opt {
|
||||
let movieid_opt = item.get("movieid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
|
||||
let episodeid_opt = item.get("episodeid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
|
||||
let songid_opt = item.get("songid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
|
||||
let file_opt = item.get("file").and_then(|f| f.as_str()).map(|s| s.to_string());
|
||||
|
||||
let p = state.player.clone();
|
||||
@@ -712,14 +755,28 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
||||
let _ = p.play(&stream_url, &ep_title).await;
|
||||
return;
|
||||
}
|
||||
} else if let Some(sid) = songid_opt {
|
||||
if let Some(jellyfin_id) = id_map.get_str(sid).await {
|
||||
let stream_url = format!("{}/Audio/{}/stream?static=true&api_key={}", base_url, jellyfin_id, token);
|
||||
tracing::info!("Player.Open songid={} (jellyfin={}) -> {}", sid, jellyfin_id, stream_url);
|
||||
let _ = p.play(&stream_url, "Muziek via Yatse").await;
|
||||
return;
|
||||
}
|
||||
} else if let Some(file) = file_opt {
|
||||
if file.starts_with("jellyfin://movie/") || file.starts_with("jellyfin://episode/") || file.starts_with("jellyfin://video/") {
|
||||
let item_id = file.rsplit('/').next().unwrap_or("");
|
||||
let stream_url = format!("{}/Videos/{}/stream?static=true&api_key={}", base_url, item_id, token);
|
||||
tracing::info!("Player.Open file='{}' -> {}", file, stream_url);
|
||||
tracing::info!("Player.Open video file='{}' -> {}", file, stream_url);
|
||||
let _ = p.play(&stream_url, "Media from Yatse").await;
|
||||
return;
|
||||
}
|
||||
if file.starts_with("jellyfin://audio/") || file.starts_with("jellyfin://song/") {
|
||||
let item_id = file.rsplit('/').next().unwrap_or("");
|
||||
let stream_url = format!("{}/Audio/{}/stream?static=true&api_key={}", base_url, item_id, token);
|
||||
tracing::info!("Player.Open audio file='{}' -> {}", file, stream_url);
|
||||
let _ = p.play(&stream_url, "Muziek via Yatse").await;
|
||||
return;
|
||||
}
|
||||
|
||||
let norm_url = normalize_playback_url(&file);
|
||||
tracing::info!("Player.Open file: '{}' -> normalized: '{}'", file, norm_url);
|
||||
@@ -1307,7 +1364,8 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
||||
"rating": s.rating.unwrap_or(0.0),
|
||||
"plot": s.overview.clone().unwrap_or_default(),
|
||||
"genre": genres,
|
||||
"episode": 0,
|
||||
"episode": s.recursive_item_count.unwrap_or(1),
|
||||
"season": s.child_count.unwrap_or(1),
|
||||
"watchedepisodes": 0,
|
||||
"file": format!("jellyfin://show/{}", s.id),
|
||||
"thumbnail": s.thumb_url(base_url),
|
||||
@@ -1352,7 +1410,8 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
||||
"rating": s.rating.unwrap_or(0.0),
|
||||
"plot": s.overview.clone().unwrap_or_default(),
|
||||
"genre": genres,
|
||||
"episode": 0,
|
||||
"episode": s.recursive_item_count.unwrap_or(1),
|
||||
"season": s.child_count.unwrap_or(1),
|
||||
"watchedepisodes": 0,
|
||||
"file": format!("jellyfin://show/{}", s.id),
|
||||
"thumbnail": s.thumb_url(base_url),
|
||||
@@ -1457,8 +1516,9 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
||||
p.get("season").and_then(|v| v.as_u64().map(|n| n as u32))
|
||||
});
|
||||
|
||||
let mut episodes_out = Vec::new();
|
||||
let base_url = &state.client.config.server_url;
|
||||
if let Some(tid) = tid_opt {
|
||||
let mut episodes_out = Vec::new();
|
||||
if let Some(jellyfin_id) = state.id_map.get_str(tid).await {
|
||||
let episodes = {
|
||||
let cache = state.episodes_cache.read().await;
|
||||
@@ -1473,7 +1533,6 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
||||
}
|
||||
};
|
||||
|
||||
let base_url = &state.client.config.server_url;
|
||||
for ep in &eps {
|
||||
let s_num = ep.parent_index_number.unwrap_or(1);
|
||||
if let Some(sf) = season_filter {
|
||||
@@ -1508,18 +1567,64 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
||||
}));
|
||||
}
|
||||
}
|
||||
let total = episodes_out.len();
|
||||
let (start, end) = parse_limits(req.params.as_ref(), total);
|
||||
let paged = episodes_out[start..end].to_vec();
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"limits": {"start": start, "end": end, "total": total},
|
||||
"episodes": paged
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
} else {
|
||||
let (start, limit) = parse_limits_raw(req.params.as_ref(), 100);
|
||||
let (total, eps) = state.client.fetch_episodes_paged(None, None, start, limit).await;
|
||||
let mut episodes_out = Vec::new();
|
||||
for ep in eps {
|
||||
let s_num = ep.parent_index_number.unwrap_or(1);
|
||||
let ep_num = ep.index_number.unwrap_or(1);
|
||||
let epid = state.id_map.get_or_create(&ep.id).await;
|
||||
let show_tid = if let Some(ref sid) = ep.series_id {
|
||||
state.id_map.get_or_create(sid).await
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let runtime = ep.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0);
|
||||
let playcount = if ep.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 };
|
||||
|
||||
episodes_out.push(json!({
|
||||
"episodeid": epid,
|
||||
"tvshowid": show_tid,
|
||||
"season": s_num,
|
||||
"episode": ep_num,
|
||||
"label": format!("{}x{:02}. {}", s_num, ep_num, ep.name),
|
||||
"title": ep.name,
|
||||
"plot": ep.overview.clone().unwrap_or_default(),
|
||||
"runtime": runtime,
|
||||
"rating": ep.rating.unwrap_or(0.0),
|
||||
"playcount": playcount,
|
||||
"file": format!("jellyfin://episode/{}", ep.id),
|
||||
"thumbnail": ep.thumb_url(base_url),
|
||||
"fanart": ep.backdrop_url(base_url),
|
||||
"art": {
|
||||
"thumb": ep.thumb_url(base_url),
|
||||
"poster": ep.poster_url(base_url),
|
||||
"fanart": ep.backdrop_url(base_url),
|
||||
}
|
||||
}));
|
||||
}
|
||||
let actual_end = start + episodes_out.len();
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"limits": {"start": start, "end": actual_end, "total": total},
|
||||
"episodes": episodes_out
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
let total = episodes_out.len();
|
||||
let (start, end) = parse_limits(req.params.as_ref(), total);
|
||||
let paged = episodes_out[start..end].to_vec();
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"limits": {"start": start, "end": end, "total": total},
|
||||
"episodes": paged
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"VideoLibrary.GetEpisodeDetails" => {
|
||||
let eid_opt = req.params.as_ref().and_then(|p| {
|
||||
@@ -1682,11 +1787,155 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
||||
})
|
||||
}
|
||||
"AudioLibrary.GetSongs" => {
|
||||
let al_opt = req.params.as_ref().and_then(|p| {
|
||||
p.get("albumid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
|
||||
});
|
||||
let ar_opt = req.params.as_ref().and_then(|p| {
|
||||
p.get("artistid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||
});
|
||||
|
||||
let album_jf = if let Some(aid) = al_opt {
|
||||
state.id_map.get_str(aid).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let artist_jf = if let Some(arid) = ar_opt {
|
||||
state.id_map.get_str(arid).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (start, limit) = parse_limits_raw(req.params.as_ref(), 100);
|
||||
let (total, songs) = state.client.fetch_songs_paged(album_jf.as_deref(), artist_jf.as_deref(), start, limit).await;
|
||||
|
||||
let base_url = &state.client.config.server_url;
|
||||
let mut songs_out = Vec::new();
|
||||
for s in songs {
|
||||
let songid = state.id_map.get_or_create(&s.id).await;
|
||||
let duration = s.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0);
|
||||
let track = s.index_number.unwrap_or(0);
|
||||
let disc = s.parent_index_number.unwrap_or(1);
|
||||
let artist = s.artist_name.as_deref().unwrap_or("Onbekend");
|
||||
let album = s.album.as_deref().unwrap_or("");
|
||||
let file = format!("jellyfin://audio/{}", s.id);
|
||||
|
||||
songs_out.push(json!({
|
||||
"songid": songid,
|
||||
"label": s.name,
|
||||
"title": s.name,
|
||||
"artist": [artist],
|
||||
"album": album,
|
||||
"duration": duration,
|
||||
"track": track,
|
||||
"disc": disc,
|
||||
"year": s.year.unwrap_or(0),
|
||||
"file": file,
|
||||
"thumbnail": s.square_url(base_url),
|
||||
"fanart": s.backdrop_url(base_url),
|
||||
"art": {
|
||||
"thumb": s.square_url(base_url),
|
||||
"fanart": s.backdrop_url(base_url)
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let actual_end = start + songs_out.len();
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"limits": {"start": 0, "end": 0, "total": 0},
|
||||
"songs": []
|
||||
"limits": {"start": start, "end": actual_end, "total": total},
|
||||
"songs": songs_out
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"AudioLibrary.GetSongDetails" => {
|
||||
let sid_opt = req.params.as_ref().and_then(|p| {
|
||||
p.get("songid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
|
||||
});
|
||||
let mut details = json!({});
|
||||
if let Some(sid) = sid_opt {
|
||||
if let Some(jellyfin_id) = state.id_map.get_str(sid).await {
|
||||
details = json!({
|
||||
"songid": sid,
|
||||
"file": format!("jellyfin://audio/{}", jellyfin_id)
|
||||
});
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"songdetails": details
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"AudioLibrary.GetAlbumDetails" => {
|
||||
let aid_opt = req.params.as_ref().and_then(|p| {
|
||||
p.get("albumid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
|
||||
});
|
||||
let mut details = json!({});
|
||||
if let Some(aid) = aid_opt {
|
||||
if let Some(jellyfin_id) = state.id_map.get_str(aid).await {
|
||||
let lib = state.client.get_library().await;
|
||||
if let Some(al) = lib.music_albums.iter().find(|x| x.id == jellyfin_id) {
|
||||
let base_url = &state.client.config.server_url;
|
||||
details = json!({
|
||||
"albumid": aid,
|
||||
"title": al.name,
|
||||
"label": al.name,
|
||||
"artist": al.artist_name.as_deref().unwrap_or(""),
|
||||
"year": al.year.unwrap_or(0),
|
||||
"thumbnail": al.square_url(base_url),
|
||||
"fanart": al.backdrop_url(base_url),
|
||||
"art": {
|
||||
"thumb": al.square_url(base_url),
|
||||
"fanart": al.backdrop_url(base_url),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"albumdetails": details
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"AudioLibrary.GetArtistDetails" => {
|
||||
let aid_opt = req.params.as_ref().and_then(|p| {
|
||||
p.get("artistid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
|
||||
});
|
||||
let mut details = json!({});
|
||||
if let Some(aid) = aid_opt {
|
||||
if let Some(jellyfin_id) = state.id_map.get_str(aid).await {
|
||||
let lib = state.client.get_library().await;
|
||||
if let Some(ar) = lib.music_artists.iter().find(|x| x.id == jellyfin_id) {
|
||||
let base_url = &state.client.config.server_url;
|
||||
details = json!({
|
||||
"artistid": aid,
|
||||
"artist": ar.name,
|
||||
"label": ar.name,
|
||||
"thumbnail": ar.square_url(base_url),
|
||||
"fanart": ar.backdrop_url(base_url),
|
||||
"art": {
|
||||
"thumb": ar.square_url(base_url),
|
||||
"fanart": ar.backdrop_url(base_url),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"artistdetails": details
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
@@ -1731,11 +1980,15 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_playback_url("plugin://plugin.video.sendtokodi/?https%3A%2F%2Fyoutube.com%2Fwatch%3Fv%3D6G45Qk0HQNY"),
|
||||
"https://youtube.com/watch?v=6G45Qk0HQNY"
|
||||
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_playback_url("https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"),
|
||||
"https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"
|
||||
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_playback_url("https://youtu.be/6G45Qk0HQNY?si=something"),
|
||||
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1778,6 +2031,7 @@ mod tests {
|
||||
has_backdrop_image: true,
|
||||
parent_primary_image_item_id: None,
|
||||
parent_backdrop_item_id: None,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1851,6 +2105,7 @@ mod tests {
|
||||
has_backdrop_image: true,
|
||||
parent_primary_image_item_id: None,
|
||||
parent_backdrop_item_id: None,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1880,6 +2135,7 @@ mod tests {
|
||||
has_backdrop_image: false,
|
||||
parent_primary_image_item_id: None,
|
||||
parent_backdrop_item_id: None,
|
||||
..Default::default()
|
||||
},
|
||||
MediaItem {
|
||||
id: "ep-uuid-2".into(),
|
||||
@@ -1902,6 +2158,7 @@ mod tests {
|
||||
has_backdrop_image: false,
|
||||
parent_primary_image_item_id: None,
|
||||
parent_backdrop_item_id: None,
|
||||
..Default::default()
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -466,6 +466,11 @@ impl YouTubeClient {
|
||||
has_backdrop_image: false,
|
||||
parent_primary_image_item_id: thumb,
|
||||
parent_backdrop_item_id: None,
|
||||
recursive_item_count: None,
|
||||
child_count: None,
|
||||
series_id: None,
|
||||
album_id: None,
|
||||
album: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -545,6 +550,11 @@ impl YouTubeClient {
|
||||
has_backdrop_image: false,
|
||||
parent_primary_image_item_id: thumb,
|
||||
parent_backdrop_item_id: None,
|
||||
recursive_item_count: None,
|
||||
child_count: None,
|
||||
series_id: None,
|
||||
album_id: None,
|
||||
album: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -648,6 +658,11 @@ impl YouTubeClient {
|
||||
has_backdrop_image: false,
|
||||
parent_primary_image_item_id: thumb,
|
||||
parent_backdrop_item_id: None,
|
||||
recursive_item_count: None,
|
||||
child_count: None,
|
||||
series_id: None,
|
||||
album_id: None,
|
||||
album: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"access_token": "ya29.a0AehfTOkVjtpy5kB4TQRuD7E0MXJAKSipRjAkysg0HnjvBvZlnb1y2WQ6UCSPxOLPliLxdri6A9AB_MlgMPHp1AXnsKHXL3dA_tkVYp3yuAhud4nUCniG0WDpgUpJ_paOAGYf5vtiJqDprd7W_8612JfnVwKW2ymR1ptNJpJ-aZivVvMsebsJcjDruq05haZGvmQtTkLTaCgYKAYESARESFQHGX2MiCxxRMG9_Lvhm7KGNtUmMIw0207",
|
||||
"access_token": "ya29.a0AehfTOmzi40Xcd4HJsYRCt-r2ZiRc2jDoynMSoynkK2pi5hAZEbmdbxN6Vid2ka9mE3rmEYafFlYoP9YZ7h4GjuGHE_nBnvLQNRHGtZCXbGKjmNc31QREZTbxc4uITVv-1VrNARf-_pgVOPywGNCfkinq8LbeDDT5wSX3Xs98J3bLpxccN9ZTErVFYMl_1BNXi_wWDPZaCgYKAZsSARESFQHGX2MiHjn2bDhDuW0uFN8u1498hw0207",
|
||||
"refresh_token": "1//09y6yuAbCKpGPCgYIARAAGAkSNwF-L9Ira4vVAsGF3K-oU7bEma5ZfUMy87MfInMEA_RvnDhxi_koUBGHGuLLAR5tbB9UZ6jcQOk",
|
||||
"expires_at": 1793817948,
|
||||
"expires_at": 1793916488,
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
Reference in New Issue
Block a user