feat(kiosk): bump v0.1.5 with Yatse artwork proxy, smart track selection, and HA buttons

This commit is contained in:
bart
2026-11-06 18:20:20 +01:00
parent 423bc75c94
commit a795c3d024
6301 changed files with 19481 additions and 111 deletions
+246 -70
View File
@@ -177,6 +177,13 @@ pub fn create_yatse_router(state: YatseServerState) -> Router {
Router::new()
.route("/", get(handle_root))
.route("/jsonrpc", post(handle_jsonrpc).get(handle_jsonrpc_get))
.route("/image", get(handle_image))
.route("/image/", get(handle_image))
.route("/image/*path", get(handle_image))
.route("/vfs", get(handle_image))
.route("/vfs/", get(handle_image))
.route("/vfs/*path", get(handle_image))
.fallback(handle_fallback)
.with_state(state)
}
@@ -184,7 +191,120 @@ async fn handle_root() -> impl IntoResponse {
"Jellyfin HTPC Kiosk (Rust) - Kodi JSON-RPC Server for Yatse"
}
fn percent_decode(input: &str) -> String {
pub fn percent_encode(s: &str) -> String {
let mut out = String::new();
for b in s.as_bytes() {
if b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_' || *b == b'.' || *b == b'~' {
out.push(*b as char);
} else {
out.push_str(&format!("%{:02x}", b));
}
}
out
}
pub fn to_kodi_art(url: &str) -> String {
if url.is_empty() {
return String::new();
}
if url.starts_with("image://") {
return url.to_string();
}
format!("image://{}/", percent_encode(url))
}
pub fn extract_image_url(uri_path: &str, query: Option<&str>) -> Option<String> {
let mut s = uri_path;
for pfx in ["/image/", "/image", "/vfs/", "/vfs"] {
if s.starts_with(pfx) {
s = &s[pfx.len()..];
break;
}
}
let mut cur = s.to_string();
for _ in 0..3 {
let decoded = percent_decode(&cur);
let trimmed = decoded.trim().trim_end_matches('/');
if let Some(inner) = trimmed.strip_prefix("image://") {
cur = inner.trim_end_matches('/').to_string();
continue;
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
cur = trimmed.to_string();
break;
}
cur = decoded;
}
if cur.starts_with("http://") || cur.starts_with("https://") {
if let Some(q) = query {
if !cur.contains('?') {
cur = format!("{}?{}", cur, q);
}
}
if cur.contains('?') {
cur = cur.trim_end_matches('/').to_string();
}
return Some(cur);
}
None
}
async fn handle_image(
State(state): State<YatseServerState>,
uri: axum::http::Uri,
) -> impl IntoResponse {
let path = uri.path();
let query = uri.query();
if let Some(target_url) = extract_image_url(path, query) {
tracing::info!("Proxying Kodi image: {} -> {}", path, target_url);
let client = reqwest::Client::new();
let mut req = client.get(&target_url);
if let Some(token) = state.client.get_token().await {
req = req.header("X-Emby-Token", token);
}
match req.send().await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
let content_type = resp.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
if let Ok(bytes) = resp.bytes().await {
return (
axum::http::StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type),
(axum::http::header::CACHE_CONTROL, "public, max-age=31536000".to_string()),
],
bytes,
).into_response();
}
} else {
tracing::warn!("Failed to fetch image from Jellyfin: status={} url={}", status, target_url);
}
}
Err(e) => {
tracing::warn!("Error requesting image from Jellyfin: {} url={}", e, target_url);
}
}
} else {
tracing::warn!("Could not extract image URL from uri: path={} query={:?}", path, query);
}
(axum::http::StatusCode::NOT_FOUND, "Image not found").into_response()
}
async fn handle_fallback(
State(state): State<YatseServerState>,
uri: axum::http::Uri,
) -> impl IntoResponse {
handle_image(State(state), uri).await
}
pub fn percent_decode(input: &str) -> String {
let mut bytes = Vec::new();
let mut chars = input.bytes();
while let Some(b) = chars.next() {
@@ -1208,6 +1328,38 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"id": id
})
}
"Files.PrepareDownload" => {
let path_opt = req.params.as_ref()
.and_then(|p| p.get("path").or_else(|| p.as_array().and_then(|a| a.first())))
.and_then(|s| s.as_str());
if let Some(path) = path_opt {
let encoded = percent_encode(path);
json!({
"jsonrpc": "2.0",
"result": {
"details": {
"path": format!("image/{}", encoded)
},
"mode": "redirect",
"protocol": "http"
},
"id": id
})
} else {
json!({
"jsonrpc": "2.0",
"result": {
"details": {
"path": ""
},
"mode": "redirect",
"protocol": "http"
},
"id": id
})
}
}
"VideoLibrary.GetMovies" => {
let lib = state.client.get_library().await;
let base_url = &state.client.config.server_url;
@@ -1230,12 +1382,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"playcount": playcount,
"genre": genres,
"file": format!("jellyfin://movie/{}", m.id),
"thumbnail": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumbnail": to_kodi_art(&m.thumb_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": m.thumb_url(base_url),
"poster": to_kodi_art(&m.poster_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"thumb": to_kodi_art(&m.thumb_url(base_url)),
},
"resume": {
"position": 0.0,
@@ -1281,12 +1433,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"playcount": playcount,
"genre": genres,
"file": format!("jellyfin://movie/{}", m.id),
"thumbnail": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumbnail": to_kodi_art(&m.thumb_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": m.thumb_url(base_url),
"poster": to_kodi_art(&m.poster_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"thumb": to_kodi_art(&m.thumb_url(base_url)),
},
"resume": {
"position": 0.0,
@@ -1326,12 +1478,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"playcount": playcount,
"genre": genres,
"file": format!("jellyfin://movie/{}", m.id),
"thumbnail": m.thumb_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumbnail": to_kodi_art(&m.thumb_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"art": {
"poster": m.poster_url(base_url),
"fanart": m.backdrop_url(base_url),
"thumb": m.thumb_url(base_url),
"poster": to_kodi_art(&m.poster_url(base_url)),
"fanart": to_kodi_art(&m.backdrop_url(base_url)),
"thumb": to_kodi_art(&m.thumb_url(base_url)),
}
}));
}
@@ -1368,12 +1520,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"season": s.child_count.unwrap_or(1),
"watchedepisodes": 0,
"file": format!("jellyfin://show/{}", s.id),
"thumbnail": s.thumb_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumbnail": to_kodi_art(&s.thumb_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url)),
"art": {
"poster": s.poster_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumb": s.thumb_url(base_url),
"poster": to_kodi_art(&s.poster_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url)),
"thumb": to_kodi_art(&s.thumb_url(base_url)),
}
}));
}
@@ -1414,12 +1566,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"season": s.child_count.unwrap_or(1),
"watchedepisodes": 0,
"file": format!("jellyfin://show/{}", s.id),
"thumbnail": s.thumb_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumbnail": to_kodi_art(&s.thumb_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url)),
"art": {
"poster": s.poster_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumb": s.thumb_url(base_url),
"poster": to_kodi_art(&s.poster_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url)),
"thumb": to_kodi_art(&s.thumb_url(base_url)),
}
});
}
@@ -1475,12 +1627,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"label": format!("Seizoen {}", s_num),
"episode": ep_count,
"watchedepisodes": 0,
"thumbnail": poster,
"fanart": backdrop,
"thumbnail": to_kodi_art(&poster),
"fanart": to_kodi_art(&backdrop),
"art": {
"poster": poster,
"fanart": backdrop,
"thumb": poster
"poster": to_kodi_art(&poster),
"fanart": to_kodi_art(&backdrop),
"thumb": to_kodi_art(&poster)
}
}));
}
@@ -1557,12 +1709,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"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),
"thumbnail": to_kodi_art(&ep.thumb_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
"art": {
"thumb": ep.thumb_url(base_url),
"poster": ep.poster_url(base_url),
"fanart": ep.backdrop_url(base_url),
"thumb": to_kodi_art(&ep.thumb_url(base_url)),
"poster": to_kodi_art(&ep.poster_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
}
}));
}
@@ -1606,12 +1758,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"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),
"thumbnail": to_kodi_art(&ep.thumb_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
"art": {
"thumb": ep.thumb_url(base_url),
"poster": ep.poster_url(base_url),
"fanart": ep.backdrop_url(base_url),
"thumb": to_kodi_art(&ep.thumb_url(base_url)),
"poster": to_kodi_art(&ep.poster_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
}
}));
}
@@ -1653,12 +1805,12 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"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),
"thumbnail": to_kodi_art(&ep.thumb_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
"art": {
"thumb": ep.thumb_url(base_url),
"poster": ep.poster_url(base_url),
"fanart": ep.backdrop_url(base_url),
"thumb": to_kodi_art(&ep.thumb_url(base_url)),
"poster": to_kodi_art(&ep.poster_url(base_url)),
"fanart": to_kodi_art(&ep.backdrop_url(base_url)),
}
});
}
@@ -1702,11 +1854,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"artistid": aid,
"artist": a.name,
"label": a.name,
"thumbnail": a.square_url(base_url),
"fanart": a.backdrop_url(base_url),
"thumbnail": to_kodi_art(&a.square_url(base_url)),
"fanart": to_kodi_art(&a.backdrop_url(base_url)),
"art": {
"thumb": a.square_url(base_url),
"fanart": a.backdrop_url(base_url),
"thumb": to_kodi_art(&a.square_url(base_url)),
"fanart": to_kodi_art(&a.backdrop_url(base_url)),
}
}));
}
@@ -1734,11 +1886,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"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),
"thumbnail": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
"art": {
"thumb": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"thumb": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
}
}));
}
@@ -1766,11 +1918,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"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),
"thumbnail": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
"art": {
"thumb": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"thumb": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
}
}));
}
@@ -1831,11 +1983,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"disc": disc,
"year": s.year.unwrap_or(0),
"file": file,
"thumbnail": s.square_url(base_url),
"fanart": s.backdrop_url(base_url),
"thumbnail": to_kodi_art(&s.square_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url)),
"art": {
"thumb": s.square_url(base_url),
"fanart": s.backdrop_url(base_url)
"thumb": to_kodi_art(&s.square_url(base_url)),
"fanart": to_kodi_art(&s.backdrop_url(base_url))
}
}));
}
@@ -1889,11 +2041,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"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),
"thumbnail": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
"art": {
"thumb": al.square_url(base_url),
"fanart": al.backdrop_url(base_url),
"thumb": to_kodi_art(&al.square_url(base_url)),
"fanart": to_kodi_art(&al.backdrop_url(base_url)),
}
});
}
@@ -1922,11 +2074,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
"artistid": aid,
"artist": ar.name,
"label": ar.name,
"thumbnail": ar.square_url(base_url),
"fanart": ar.backdrop_url(base_url),
"thumbnail": to_kodi_art(&ar.square_url(base_url)),
"fanart": to_kodi_art(&ar.backdrop_url(base_url)),
"art": {
"thumb": ar.square_url(base_url),
"fanart": ar.backdrop_url(base_url),
"thumb": to_kodi_art(&ar.square_url(base_url)),
"fanart": to_kodi_art(&ar.backdrop_url(base_url)),
}
});
}
@@ -2222,4 +2374,28 @@ mod tests {
"https://youtube.com/watch?v=eMEIt6_mGSI&is=hEdy88z6Nhf7gRSE"
);
}
#[test]
fn test_extract_image_url_variations() {
let original = "http://10.2.0.4:8096/jellyfin/Items/125c/Images/Primary?fillWidth=320&fillHeight=480&quality=85";
let kodi_art = to_kodi_art(original);
assert!(kodi_art.starts_with("image://"));
assert!(kodi_art.ends_with('/'));
// Case 1: Yatse calls /image/<encoded>
let path1 = format!("/image/{}", percent_encode(&kodi_art));
let res1 = extract_image_url(&path1, None).expect("extracted 1");
assert_eq!(res1, original);
// Case 2: Yatse calls /image/<single-encoded-http-with-trailing-slash>
let path2 = format!("/image/{}/", percent_encode(original));
let res2 = extract_image_url(&path2, None).expect("extracted 2");
assert_eq!(res2, original);
// Case 3: Yatse calls /vfs/<kodi-art>
let path3 = format!("/vfs/{}", percent_encode(&kodi_art));
let res3 = extract_image_url(&path3, None).expect("extracted 3");
assert_eq!(res3, original);
}
}