Compare commits

..
1 Commits
10 changed files with 437 additions and 34 deletions
+1 -1
View File
@@ -1460,7 +1460,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jellyfin-kiosk-rs"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"axum",
"eframe",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellyfin-kiosk-rs"
version = "0.1.3"
version = "0.1.4"
edition = "2021"
[dependencies]
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
2026-11-05T21:13:59.466559Z  INFO jellyfin_kiosk_rs: Starting Jellyfin HTPC Kiosk (Rust)...
2026-11-05T21:13:59.467206Z  INFO jellyfin_kiosk_rs::config: Loaded config from ../config.yaml
2026-11-05T21:13:59.474938Z  INFO jellyfin_kiosk_rs::player: libmpv initialized successfully (vo=libmpv, hwdec=auto)
2026-11-05T21:13:59.549439Z  INFO jellyfin_kiosk_rs::yatse: Yatse Kodi TCP JSON-RPC socket server listening on 0.0.0.0:9090
2026-11-05T21:13:59.549444Z  INFO jellyfin_kiosk_rs: Yatse Kodi JSON-RPC server listening on http://0.0.0.0:8080
2026-11-05T21:13:59.549923Z  INFO jellyfin_kiosk_rs::eventserver: Kodi EventServer listening on UDP 0.0.0.0:9777
2026-11-05T21:13:59.551471Z  INFO jellyfin_kiosk_rs: Published Zeroconf services for Yatse discovery: 'Kodi (bart-laptop)'
2026-11-05T21:13:59.691500Z  INFO winit::platform_impl::linux::x11::window: Guessed window scale factor: 1
2026-11-05T21:13:59.729454Z  INFO jellyfin_kiosk_rs::youtube: Loaded cached YouTube token from youtube_token.json
Cannot load libcuda.so.1
2026-11-05T21:13:59.854273Z  INFO jellyfin_kiosk_rs::player: libmpv OpenGL render context created successfully!
2026-11-05T21:13:59.916581Z  INFO jellyfin_kiosk_rs::jellyfin: Authenticated successfully with Jellyfin as bart
+23 -1
View File
@@ -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));
}
+102 -5
View File
@@ -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,
});
}
}
+1 -1
View File
@@ -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) };
+270 -13
View File
@@ -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,7 +1567,6 @@ 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();
@@ -1520,6 +1578,53 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
},
"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
})
}
}
"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()
},
]);
}
+15
View File
@@ -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,
});
}
}
+2 -2
View File
@@ -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"
}