feat(yatse): support Addons.ExecuteAddon YouTube sharing and Playlist endpoints

This commit is contained in:
bart
2026-11-05 19:12:04 +01:00
parent 888996f4f3
commit 289f4a4ff5
4 changed files with 113 additions and 2 deletions
+111
View File
@@ -217,6 +217,58 @@ pub fn normalize_playback_url(file: &str) -> String {
trimmed.to_string()
}
pub fn extract_addon_share_url(params: &Value) -> Option<String> {
// 1. Direct fields
for key in ["data", "url", "file"] {
if let Some(v) = params.get(key).and_then(|v| v.as_str()) {
return Some(v.to_string());
}
}
// 2. Nested under "params"
if let Some(inner) = params.get("params") {
if let Some(obj) = inner.as_object() {
for key in ["data", "url", "file"] {
if let Some(v) = obj.get(key).and_then(|v| v.as_str()) {
return Some(v.to_string());
}
}
} else if let Some(s) = inner.as_str() {
let q = s.trim_start_matches('?');
for pair in q.split('&') {
let mut kv = pair.splitn(2, '=');
if let (Some(k), Some(v)) = (kv.next(), kv.next()) {
if k == "data" || k == "url" || k == "file" {
return Some(v.to_string());
}
}
}
if s.contains("http://") || s.contains("https://") || s.contains("plugin://") {
return Some(s.to_string());
}
} else if let Some(arr) = inner.as_array() {
for item in arr {
if let Some(s) = item.as_str() {
let q = s.trim_start_matches('?');
for pair in q.split('&') {
let mut kv = pair.splitn(2, '=');
if let (Some(k), Some(v)) = (kv.next(), kv.next()) {
if k == "data" || k == "url" || k == "file" {
return Some(v.to_string());
}
}
}
if s.contains("http://") || s.contains("https://") || s.contains("plugin://") {
return Some(s.to_string());
}
}
}
}
}
None
}
async fn handle_jsonrpc_get(
State(state): State<YatseServerState>,
uri: axum::http::Uri,
@@ -1009,8 +1061,49 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
})
}
"Addons.ExecuteAddon" => {
if let Some(ref params) = req.params {
if let Some(raw_url) = extract_addon_share_url(params) {
let decoded_url = percent_decode(&raw_url);
let norm_url = normalize_playback_url(&decoded_url);
tracing::info!("Addons.ExecuteAddon share requested: raw='{}' -> norm='{}'", raw_url, norm_url);
let p = state.player.clone();
tokio::spawn(async move {
let _ = p.play(&norm_url, "YouTube via Yatse").await;
});
} else {
tracing::warn!("Addons.ExecuteAddon received without resolvable URL: {:?}", params);
}
}
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Playlist.Add" | "Playlist.Insert" => {
if let Some(ref params) = req.params {
if let Some(item) = params.get("item") {
if let Some(file) = item.get("file").and_then(|f| f.as_str()) {
let norm_url = normalize_playback_url(file);
tracing::info!("Playlist.Add requested file: '{}' -> '{}'", file, norm_url);
let p = state.player.clone();
tokio::spawn(async move {
let _ = p.play(&norm_url, "Media from Yatse").await;
});
}
}
}
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Playlist.Clear" => {
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Playlist.GetItems" => {
json!({
"jsonrpc": "2.0",
"result": {
"items": [],
"limits": {"start": 0, "end": 0, "total": 0}
},
"id": id
})
}
"Files.GetSources" => {
let media = req.params.as_ref()
.and_then(|p| p.get("media").or_else(|| p.as_array().and_then(|a| a.first())))
@@ -1854,4 +1947,22 @@ mod tests {
assert_eq!(eps[1]["title"], "Cat's in the Bag...");
assert_eq!(eps[1]["episode"], 2);
}
#[test]
fn test_extract_addon_share_url() {
let payload = json!({
"addonid": "script.yatse.kodi",
"params": {
"action": "share",
"data": "https%3A%2F%2Fyoutube.com%2Fwatch%3Fv%3DeMEIt6_mGSI%26is%3DhEdy88z6Nhf7gRSE",
"queue": "false",
"type": "unresolvedurl"
}
});
let extracted = extract_addon_share_url(&payload).expect("extracted url");
assert_eq!(
percent_decode(&extracted),
"https://youtube.com/watch?v=eMEIt6_mGSI&is=hEdy88z6Nhf7gRSE"
);
}
}