fix(kiosk): auto-hide OSD, prioritize hardware-decoded formats, clean YouTube share URLs

This commit is contained in:
bart
2026-11-05 22:20:20 +01:00
parent 289f4a4ff5
commit 423bc75c94
10 changed files with 437 additions and 34 deletions
+280 -23
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,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()
},
]);
}