feat(kiosk): bump v0.1.5 with Yatse artwork proxy, smart track selection, and HA buttons
This commit is contained in:
+246
-70
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user