refactor: split addon_protocol.rs into per-concern submodules

addon_protocol.rs was 1186 lines mixing URL handling, asset resolution,
catalog parsing, manifest parsing, and meta-link classification. Split
into addon_protocol/{url,assets,catalogs,manifest_parse,meta_links}.rs:

- url.rs: manifest URL normalization, local/ipv4 detection, resource URL building
- assets.rs: asset URL resolution, manifest-asset/live-manifest merging
- catalogs.rs: catalog extraction, resource/extra support checks
- manifest_parse.rs: raw manifest -> normalized descriptor parsing, addon
  descriptor normalization
- meta_links.rs: cast/director meta-link classification

addon_protocol.rs is now just module wiring plus the existing test suite.
No behavior change — same 393 tests pass.
This commit is contained in:
KhooLy 2026-07-30 15:42:41 +03:00
parent 41e19ee76f
commit 8eafbb2120
6 changed files with 883 additions and 858 deletions

View file

@ -1,868 +1,28 @@
use serde_json::{Map, Value, json};
use std::collections::HashSet;
pub(crate) fn is_http_url(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
pub(crate) fn strip_http_scheme(value: &str) -> &str {
value
.strip_prefix("http://")
.or_else(|| value.strip_prefix("https://"))
.or_else(|| value.strip_prefix("HTTP://"))
.or_else(|| value.strip_prefix("HTTPS://"))
.unwrap_or(value)
}
pub(crate) fn is_ipv4_like_host(value: &str) -> bool {
let host = value
.split('/')
.next()
.unwrap_or(value)
.split(':')
.next()
.unwrap_or(value);
let parts: Vec<&str> = host.split('.').collect();
parts.len() == 4 && parts.iter().all(|part| part.parse::<u8>().is_ok())
}
pub(crate) fn is_local_url(value: &str) -> bool {
let lower = strip_http_scheme(value).to_ascii_lowercase();
if lower.starts_with("localhost")
|| lower.starts_with("127.")
|| lower.starts_with("10.")
|| lower.starts_with("192.168.")
{
return true;
}
// 172.16.0.0/12 private range
if let Some(rest) = lower.strip_prefix("172.") {
if let Some(second_octet) = rest.split('.').next().and_then(|s| s.parse::<u8>().ok()) {
if (16..=31).contains(&second_octet) {
return true;
}
}
}
false
}
pub(crate) fn normalize_manifest_url(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return String::new();
}
let lower = trimmed.to_ascii_lowercase();
let with_scheme = if lower.starts_with("stremio://") {
format!("https://{}", &trimmed[10..])
} else if lower.starts_with("http://") {
if is_local_url(trimmed) {
trimmed.to_string()
} else {
format!("https://{}", &trimmed[7..])
}
} else if lower.starts_with("https://") {
trimmed.to_string()
} else if lower.starts_with("127.0.0.1")
|| lower.starts_with("10.0.2.2")
|| lower.starts_with("localhost")
|| is_ipv4_like_host(trimmed)
{
format!("http://{trimmed}")
} else {
format!("https://{trimmed}")
};
if with_scheme.to_ascii_lowercase().ends_with("manifest.json") {
with_scheme
} else if with_scheme.ends_with('/') {
format!("{with_scheme}manifest.json")
} else {
format!("{with_scheme}/manifest.json")
}
}
pub(crate) fn identity(raw: &str) -> String {
normalize_manifest_url(raw)
.trim_end_matches('/')
.trim_start_matches("https://")
.trim_start_matches("http://")
.to_string()
}
pub(crate) fn manifest_candidates(raw: &str) -> Vec<String> {
let normalized = normalize_manifest_url(raw);
let mut values = vec![normalized.clone()];
if is_local_url(&normalized) && normalized.to_ascii_lowercase().starts_with("https://") {
let fallback = format!("http://{}", &normalized[8..]);
if !values.contains(&fallback) {
values.push(fallback);
}
}
values
}
pub(crate) fn manifest_fetch_plan_json(raw: &str) -> Option<String> {
let normalized_transport_url = normalize_manifest_url(raw);
if normalized_transport_url.is_empty() {
return None;
}
serde_json::to_string(&json!({
"normalizedTransportUrl": normalized_transport_url,
"cacheKey": format!("manifest_v10_{}", normalized_transport_url),
"candidateUrls": manifest_candidates(&normalized_transport_url)
}))
.ok()
}
pub(crate) fn base_url(raw: &str) -> String {
let normalized = normalize_manifest_url(raw);
let without_manifest = match normalized.to_ascii_lowercase().rfind("manifest.json") {
Some(index) => normalized[..index].to_string(),
None => normalized,
};
let mut base = if without_manifest.ends_with('/') {
without_manifest
} else {
format!("{without_manifest}/")
};
let lower = base.to_ascii_lowercase();
if (lower.contains("localhost") || lower.contains("127.0.0.1")) && lower.starts_with("https://")
{
base = format!("http://{}", &base[8..]);
}
base
}
pub(crate) fn prefer_https_asset_url(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let lower = trimmed.to_ascii_lowercase();
if lower.starts_with("http://") {
if is_local_url(trimmed) {
Some(trimmed.to_string())
} else {
Some(format!("https://{}", &trimmed[7..]))
}
} else if trimmed.starts_with("//") {
Some(format!("https:{trimmed}"))
} else {
Some(trimmed.to_string())
}
}
pub(crate) fn encode_path_segment(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*');
if keep {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
pub(crate) fn build_resource_url(
raw: &str,
resource: &str,
content_type: &str,
id: &str,
extra_json: Option<&str>,
) -> String {
let extra_path = extra_json
.and_then(|value| serde_json::from_str::<Map<String, Value>>(value).ok())
.map(|map| {
map.into_iter()
.filter_map(|(key, value)| {
let text = value
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| value.to_string());
if text.trim().is_empty() {
None
} else {
Some(format!(
"{}={}",
encode_path_segment(&key),
encode_path_segment(&text)
))
}
})
.collect::<Vec<_>>()
.join("&")
})
.filter(|value| !value.is_empty())
.map(|value| format!("/{value}"))
.unwrap_or_default();
format!(
"{}{}/{}/{}{}.json",
base_url(raw),
resource,
encode_path_segment(content_type),
encode_path_segment(id),
extra_path
)
}
pub(crate) fn string_array(json: &Value, key: &str) -> Vec<Value> {
json.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
item.as_str()
.filter(|text| !text.is_empty())
.map(|text| Value::String(text.to_string()))
})
.collect()
})
.unwrap_or_default()
}
pub(crate) fn first_text(
json: &Value,
behavior_hints: Option<&Value>,
keys: &[&str],
) -> Option<String> {
keys.iter().find_map(|key| {
json.get(*key)
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.or_else(|| {
behavior_hints
.and_then(|hints| hints.get(*key))
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
})
.map(str::to_string)
})
}
pub(crate) fn resolve_asset_url(asset: Option<String>, manifest_url: &str) -> Option<String> {
let secure = prefer_https_asset_url(asset?.as_str())?;
if is_http_url(&secure) {
return Some(secure);
}
if secure.starts_with('/') {
let base = base_url(manifest_url);
let scheme_end = base.find("://").map(|index| index + 3)?;
let host_end = base[scheme_end..]
.find('/')
.map(|index| scheme_end + index)
.unwrap_or(base.len());
return prefer_https_asset_url(&format!("{}{}", &base[..host_end], secure));
}
prefer_https_asset_url(&format!("{}{}", base_url(manifest_url), secure))
}
fn text_value<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
value
.get(key)
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
}
fn non_empty_array(value: Option<&Value>) -> Option<Value> {
value
.and_then(Value::as_array)
.filter(|items| !items.is_empty())
.cloned()
.map(Value::Array)
}
fn set_or_null(map: &mut Map<String, Value>, key: &str, value: Option<String>) {
map.insert(
key.to_string(),
value.map(Value::String).unwrap_or(Value::Null),
);
}
fn value_has_content(value: &Value) -> bool {
match value {
Value::Null => false,
Value::String(text) => !text.trim().is_empty(),
Value::Array(items) => !items.is_empty(),
Value::Object(map) => !map.is_empty(),
Value::Bool(_) | Value::Number(_) => true,
}
}
pub(crate) fn resolve_manifest_assets_json(descriptor_json: &str) -> Option<String> {
let mut descriptor: Value = serde_json::from_str(descriptor_json).ok()?;
let transport_url = descriptor
.get("transportUrl")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let normalized_transport_url = normalize_manifest_url(&transport_url);
descriptor["transportUrl"] = Value::String(normalized_transport_url.clone());
let manifest = descriptor.get_mut("manifest")?.as_object_mut()?;
let logo = text_value(&Value::Object(manifest.clone()), "logo").map(str::to_string);
let background = text_value(&Value::Object(manifest.clone()), "background").map(str::to_string);
let resolved_background = resolve_asset_url(background.clone(), &normalized_transport_url);
let resolved_logo =
resolve_asset_url(logo, &normalized_transport_url).or_else(|| resolved_background.clone());
let description = manifest
.get("description")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(str::to_string);
set_or_null(manifest, "description", description);
set_or_null(manifest, "logo", resolved_logo);
set_or_null(manifest, "background", resolved_background);
serde_json::to_string(&descriptor).ok()
}
pub(crate) fn merge_live_manifest_json(
descriptor_json: &str,
live_json: Option<&str>,
unknown_name: &str,
) -> Option<String> {
let Some(live_json) = live_json.filter(|value| !value.trim().is_empty()) else {
return resolve_manifest_assets_json(descriptor_json);
};
let mut descriptor: Value = serde_json::from_str(descriptor_json).ok()?;
let live: Value = serde_json::from_str(live_json).ok()?;
let transport_url = descriptor
.get("transportUrl")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let normalized_transport_url = normalize_manifest_url(&transport_url);
descriptor["transportUrl"] = Value::String(normalized_transport_url.clone());
let current_manifest_snapshot = descriptor.get("manifest")?.clone();
let current_manifest = current_manifest_snapshot.as_object()?;
let live_manifest = live.get("manifest")?.as_object()?;
let manifest = descriptor.get_mut("manifest")?.as_object_mut()?;
if let Some(id) = text_value(&Value::Object(live_manifest.clone()), "id") {
manifest.insert("id".to_string(), Value::String(id.to_string()));
}
if let Some(name) = text_value(&Value::Object(live_manifest.clone()), "name")
.filter(|name| *name != unknown_name)
{
manifest.insert("name".to_string(), Value::String(name.to_string()));
}
if let Some(description) = text_value(&Value::Object(live_manifest.clone()), "description") {
manifest.insert(
"description".to_string(),
Value::String(description.to_string()),
);
}
for (key, value) in live_manifest {
if matches!(
key.as_str(),
"id" | "name" | "description" | "logo" | "background"
) {
continue;
}
if matches!(
key.as_str(),
"resources" | "types" | "catalogs" | "idPrefixes"
) {
if let Some(value) = non_empty_array(Some(value)) {
manifest.insert(key.to_string(), value);
}
continue;
}
if value_has_content(value) {
manifest.insert(key.to_string(), value.clone());
}
}
let current_logo = current_manifest
.get("logo")
.and_then(Value::as_str)
.map(str::to_string);
let current_background = current_manifest
.get("background")
.and_then(Value::as_str)
.map(str::to_string);
let live_logo = live_manifest
.get("logo")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(str::to_string);
let live_background = live_manifest
.get("background")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(str::to_string);
let resolved_current_logo = resolve_asset_url(current_logo, &normalized_transport_url);
let resolved_current_background =
resolve_asset_url(current_background, &normalized_transport_url);
let logo = live_logo
.or(resolved_current_logo)
.or_else(|| live_background.clone())
.or_else(|| resolved_current_background.clone());
let background = live_background.or(resolved_current_background);
set_or_null(manifest, "logo", logo);
set_or_null(manifest, "background", background);
serde_json::to_string(&descriptor).ok()
}
pub(crate) fn parse_catalogs(json: &Value) -> Vec<Value> {
json.get("catalogs")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let object = item.as_object()?;
let mut map = object.clone();
let extra = object
.get("extra")
.and_then(Value::as_array)
.map(|extras| {
extras
.iter()
.filter_map(|extra| {
let extra_object = extra.as_object()?;
let mut map = Map::new();
if let Some(name) = extra_object
.get("name")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
{
map.insert(
"name".to_string(),
Value::String(name.to_string()),
);
}
let options = string_array(extra, "options");
if !options.is_empty() {
map.insert("options".to_string(), Value::Array(options));
}
if let Some(is_required) =
extra_object.get("isRequired").and_then(Value::as_bool)
{
map.insert(
"isRequired".to_string(),
Value::Bool(is_required),
);
}
if let Some(options_limit) =
extra_object.get("optionsLimit").and_then(Value::as_i64)
{
map.insert(
"optionsLimit".to_string(),
json!(options_limit as i32),
);
}
if let Some(default_value) =
extra_object.get("default").and_then(Value::as_str)
{
map.insert(
"default".to_string(),
Value::String(default_value.to_string()),
);
}
Some(Value::Object(map))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !extra.is_empty() {
map.insert("extra".to_string(), Value::Array(extra));
}
let extras = map
.get("extra")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let extra_supported =
string_array(&Value::Object(map.clone()), "extraSupported");
let supports_initial_load = !extras.iter().any(|extra| {
extra
.get("isRequired")
.and_then(Value::as_bool)
.unwrap_or(false)
});
let supports_search = extra_supported.iter().any(|extra| {
extra
.as_str()
.is_some_and(|name| name.eq_ignore_ascii_case("search"))
}) || extras.iter().any(|extra| {
extra
.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name.eq_ignore_ascii_case("search"))
});
let has_required_extra_except_genre = extras.iter().any(|extra| {
extra
.get("isRequired")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !extra
.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name.eq_ignore_ascii_case("genre"))
});
map.insert(
"supportsInitialLoad".to_string(),
Value::Bool(supports_initial_load),
);
map.insert("supportsSearch".to_string(), Value::Bool(supports_search));
map.insert(
"hasRequiredExtraExceptGenre".to_string(),
Value::Bool(has_required_extra_except_genre),
);
Some(Value::Object(map))
})
.collect()
})
.unwrap_or_default()
}
// pub rather than pub(crate): re-exported under fuzz_targets for the `fuzz/`
// crate (see lib.rs). Not part of the supported public API otherwise.
pub fn parse_manifest(body: &str, transport_url: &str, unknown_name: &str) -> Option<String> {
let json: Value = serde_json::from_str(body).ok()?;
let behavior_hints = json.get("behaviorHints");
let logo = first_text(
&json,
behavior_hints,
&["logo", "icon", "iconUrl", "poster", "posterUrl"],
);
let background = first_text(
&json,
behavior_hints,
&["background", "backgroundUrl", "backdrop", "backdropUrl"],
);
let description = first_text(
&json,
behavior_hints,
&[
"description",
"shortDescription",
"longDescription",
"summary",
],
);
let resources = json
.get("resources")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let id_prefixes = string_array(&json, "idPrefixes");
let mut manifest = json.as_object().cloned().unwrap_or_default();
manifest.insert(
"id".to_string(),
Value::String(
json.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
),
);
manifest.insert(
"name".to_string(),
Value::String(
json.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.unwrap_or(unknown_name)
.to_string(),
),
);
manifest.insert(
"description".to_string(),
description.map(Value::String).unwrap_or(Value::Null),
);
manifest.insert(
"version".to_string(),
first_text(&json, behavior_hints, &["version"])
.map(Value::String)
.unwrap_or(Value::Null),
);
manifest.insert("resources".to_string(), Value::Array(resources));
manifest.insert(
"supportsCatalog".to_string(),
Value::Bool(
json.get("resources")
.and_then(Value::as_array)
.is_some_and(|resources| {
resources.iter().any(|resource| match resource {
Value::String(name) => canonical_resource_name(name) == "catalog",
Value::Object(resource) => resource
.get("name")
.and_then(Value::as_str)
.is_some_and(|name| canonical_resource_name(name) == "catalog"),
_ => false,
})
}),
),
);
manifest.insert(
"types".to_string(),
Value::Array(string_array(&json, "types")),
);
manifest.insert("catalogs".to_string(), Value::Array(parse_catalogs(&json)));
manifest.insert(
"idPrefixes".to_string(),
if id_prefixes.is_empty() {
Value::Null
} else {
Value::Array(id_prefixes)
},
);
manifest.insert(
"logo".to_string(),
resolve_asset_url(logo, transport_url)
.map(Value::String)
.unwrap_or(Value::Null),
);
manifest.insert(
"background".to_string(),
resolve_asset_url(background, transport_url)
.map(Value::String)
.unwrap_or(Value::Null),
);
manifest.insert(
"configurable".to_string(),
behavior_hints
.and_then(|hints| hints.get("configurable"))
.and_then(Value::as_bool)
.map(Value::Bool)
.unwrap_or(Value::Null),
);
let descriptor = json!({
"manifest": Value::Object(manifest),
"transportUrl": transport_url
});
serde_json::to_string(&descriptor).ok()
}
pub(crate) fn canonical_resource_name(value: &str) -> String {
match value.to_ascii_lowercase().trim_end_matches('s') {
"metadata" => "meta".to_string(),
"subtitle" => "subtitle".to_string(),
other => other.to_string(),
}
}
pub(crate) fn to_string_vec(value: Option<&Value>) -> Vec<String> {
match value {
Some(Value::Array(items)) => items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.filter(|item| !item.is_empty())
.collect(),
Some(Value::String(text)) if !text.is_empty() => vec![text.to_string()],
_ => Vec::new(),
}
}
pub(crate) fn supports_resource(
manifest_json: &str,
resource_name: &str,
content_type: Option<&str>,
id: Option<&str>,
) -> bool {
let Ok(manifest) = serde_json::from_str::<Value>(manifest_json) else {
return false;
};
let expected = canonical_resource_name(resource_name);
let manifest_types = to_string_vec(manifest.get("types"));
let manifest_prefixes = to_string_vec(manifest.get("idPrefixes"));
manifest
.get("resources")
.and_then(Value::as_array)
.map(|resources| {
resources.iter().any(|resource| {
let (name, types, prefixes) = match resource {
Value::String(name) => (
name.as_str(),
manifest_types.clone(),
manifest_prefixes.clone(),
),
Value::Object(map) => {
let Some(name) = map.get("name").and_then(Value::as_str) else {
return false;
};
let types = to_string_vec(map.get("types"))
.into_iter()
.chain(to_string_vec(map.get("type")))
.collect::<Vec<_>>();
let prefixes = to_string_vec(map.get("idPrefixes"))
.into_iter()
.chain(to_string_vec(map.get("idPrefix")))
.collect::<Vec<_>>();
(
name,
if types.is_empty() {
manifest_types.clone()
} else {
types
},
if prefixes.is_empty() {
manifest_prefixes.clone()
} else {
prefixes
},
)
}
_ => return false,
};
if canonical_resource_name(name) != expected {
return false;
}
if let Some(content_type) = content_type {
if !types.is_empty()
&& !types
.iter()
.any(|item| item.eq_ignore_ascii_case(content_type))
{
return false;
}
}
if let Some(id) = id {
if canonical_resource_name(name) != "catalog"
&& !prefixes.is_empty()
&& !prefixes.iter().any(|prefix| id.starts_with(prefix))
{
return false;
}
}
true
})
})
.unwrap_or(false)
}
pub(crate) fn catalog_supports_extra(catalog_json: &str, extra_name: &str) -> bool {
let Ok(catalog) = serde_json::from_str::<Value>(catalog_json) else {
return false;
};
catalog
.get("extraSupported")
.and_then(Value::as_array)
.is_some_and(|items| {
items
.iter()
.filter_map(Value::as_str)
.any(|item| item.eq_ignore_ascii_case(extra_name))
})
|| catalog
.get("extra")
.and_then(Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name.eq_ignore_ascii_case(extra_name))
})
})
}
pub(crate) fn normalize_addon_descriptor_json(addon_json: &str) -> Option<String> {
let addon: Value = serde_json::from_str(addon_json).ok()?;
if addon.get("manifest").is_some_and(Value::is_object) {
let mut normalized = addon.as_object()?.clone();
normalized
.entry("transportUrl")
.or_insert_with(|| Value::String(String::new()));
return serde_json::to_string(&normalized).ok();
}
let transport_url = addon
.get("transportUrl")
.or_else(|| addon.get("id"))
.and_then(Value::as_str)
.unwrap_or("");
parse_manifest(addon_json, transport_url, "Unknown Addon")
}
pub(crate) fn catalog_requires_extra(catalog_json: &str, extra_name: &str) -> bool {
let Ok(catalog) = serde_json::from_str::<Value>(catalog_json) else {
return false;
};
catalog
.get("extra")
.and_then(Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name.eq_ignore_ascii_case(extra_name))
&& item.get("isRequired").and_then(Value::as_bool) == Some(true)
})
})
}
pub(crate) fn catalog_has_required_extra_except(
catalog_json: &str,
allowed_names_json: &str,
) -> bool {
let Ok(catalog) = serde_json::from_str::<Value>(catalog_json) else {
return false;
};
let allowed_names = serde_json::from_str::<Vec<String>>(allowed_names_json)
.unwrap_or_default()
.into_iter()
.collect::<HashSet<_>>();
catalog
.get("extra")
.and_then(Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("isRequired").and_then(Value::as_bool) == Some(true)
&& item
.get("name")
.and_then(Value::as_str)
.map(|name| !allowed_names.contains(&name.to_ascii_lowercase()))
.unwrap_or(true)
})
})
}
fn meta_link_kind(category: &str) -> Option<&'static str> {
let category = category.to_lowercase();
if ["cast", "actor", "actors", "starring"]
.iter()
.any(|key| category.contains(key))
{
Some("cast")
} else if category.contains("director") {
Some("director")
} else {
None
}
}
pub(crate) fn classify_meta_links_json(links_json: &str) -> Option<String> {
let links: Vec<Value> = serde_json::from_str(links_json).ok()?;
let mut cast: Vec<Value> = Vec::new();
let mut directors: Vec<Value> = Vec::new();
for link in links {
let category = link.get("category").and_then(Value::as_str).unwrap_or("");
match meta_link_kind(category) {
Some("cast") => cast.push(link),
Some("director") => directors.push(link),
_ => {}
}
}
serde_json::to_string(&json!({ "cast": cast, "directors": directors })).ok()
}
mod assets;
mod catalogs;
mod manifest_parse;
mod meta_links;
mod url;
pub(crate) use assets::{merge_live_manifest_json, resolve_manifest_assets_json};
pub(crate) use catalogs::{
catalog_has_required_extra_except, catalog_requires_extra, catalog_supports_extra,
supports_resource,
};
pub(crate) use manifest_parse::normalize_addon_descriptor_json;
pub use manifest_parse::parse_manifest;
pub(crate) use meta_links::classify_meta_links_json;
pub(crate) use url::{
base_url, build_resource_url, identity, is_http_url, manifest_candidates,
manifest_fetch_plan_json, normalize_manifest_url, prefer_https_asset_url,
};
#[cfg(test)]
mod tests {
use super::{
build_resource_url, merge_live_manifest_json, normalize_addon_descriptor_json,
parse_manifest, resolve_manifest_assets_json, supports_resource,
};
use serde_json::{Value, json};
use serde_json::{json, Value};
#[test]
fn resolve_manifest_assets_normalizes_transport_and_relative_assets() {

View file

@ -0,0 +1,205 @@
use super::url::{base_url, is_http_url, normalize_manifest_url, prefer_https_asset_url};
use serde_json::{Map, Value};
pub(crate) fn string_array(json: &Value, key: &str) -> Vec<Value> {
json.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
item.as_str()
.filter(|text| !text.is_empty())
.map(|text| Value::String(text.to_string()))
})
.collect()
})
.unwrap_or_default()
}
pub(crate) fn first_text(
json: &Value,
behavior_hints: Option<&Value>,
keys: &[&str],
) -> Option<String> {
keys.iter().find_map(|key| {
json.get(*key)
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.or_else(|| {
behavior_hints
.and_then(|hints| hints.get(*key))
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
})
.map(str::to_string)
})
}
pub(crate) fn resolve_asset_url(asset: Option<String>, manifest_url: &str) -> Option<String> {
let secure = prefer_https_asset_url(asset?.as_str())?;
if is_http_url(&secure) {
return Some(secure);
}
if secure.starts_with('/') {
let base = base_url(manifest_url);
let scheme_end = base.find("://").map(|index| index + 3)?;
let host_end = base[scheme_end..]
.find('/')
.map(|index| scheme_end + index)
.unwrap_or(base.len());
return prefer_https_asset_url(&format!("{}{}", &base[..host_end], secure));
}
prefer_https_asset_url(&format!("{}{}", base_url(manifest_url), secure))
}
fn text_value<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
value
.get(key)
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
}
fn non_empty_array(value: Option<&Value>) -> Option<Value> {
value
.and_then(Value::as_array)
.filter(|items| !items.is_empty())
.cloned()
.map(Value::Array)
}
fn set_or_null(map: &mut Map<String, Value>, key: &str, value: Option<String>) {
map.insert(
key.to_string(),
value.map(Value::String).unwrap_or(Value::Null),
);
}
fn value_has_content(value: &Value) -> bool {
match value {
Value::Null => false,
Value::String(text) => !text.trim().is_empty(),
Value::Array(items) => !items.is_empty(),
Value::Object(map) => !map.is_empty(),
Value::Bool(_) | Value::Number(_) => true,
}
}
pub(crate) fn resolve_manifest_assets_json(descriptor_json: &str) -> Option<String> {
let mut descriptor: Value = serde_json::from_str(descriptor_json).ok()?;
let transport_url = descriptor
.get("transportUrl")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let normalized_transport_url = normalize_manifest_url(&transport_url);
descriptor["transportUrl"] = Value::String(normalized_transport_url.clone());
let manifest = descriptor.get_mut("manifest")?.as_object_mut()?;
let logo = text_value(&Value::Object(manifest.clone()), "logo").map(str::to_string);
let background = text_value(&Value::Object(manifest.clone()), "background").map(str::to_string);
let resolved_background = resolve_asset_url(background.clone(), &normalized_transport_url);
let resolved_logo =
resolve_asset_url(logo, &normalized_transport_url).or_else(|| resolved_background.clone());
let description = manifest
.get("description")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(str::to_string);
set_or_null(manifest, "description", description);
set_or_null(manifest, "logo", resolved_logo);
set_or_null(manifest, "background", resolved_background);
serde_json::to_string(&descriptor).ok()
}
pub(crate) fn merge_live_manifest_json(
descriptor_json: &str,
live_json: Option<&str>,
unknown_name: &str,
) -> Option<String> {
let Some(live_json) = live_json.filter(|value| !value.trim().is_empty()) else {
return resolve_manifest_assets_json(descriptor_json);
};
let mut descriptor: Value = serde_json::from_str(descriptor_json).ok()?;
let live: Value = serde_json::from_str(live_json).ok()?;
let transport_url = descriptor
.get("transportUrl")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let normalized_transport_url = normalize_manifest_url(&transport_url);
descriptor["transportUrl"] = Value::String(normalized_transport_url.clone());
let current_manifest_snapshot = descriptor.get("manifest")?.clone();
let current_manifest = current_manifest_snapshot.as_object()?;
let live_manifest = live.get("manifest")?.as_object()?;
let manifest = descriptor.get_mut("manifest")?.as_object_mut()?;
if let Some(id) = text_value(&Value::Object(live_manifest.clone()), "id") {
manifest.insert("id".to_string(), Value::String(id.to_string()));
}
if let Some(name) = text_value(&Value::Object(live_manifest.clone()), "name")
.filter(|name| *name != unknown_name)
{
manifest.insert("name".to_string(), Value::String(name.to_string()));
}
if let Some(description) = text_value(&Value::Object(live_manifest.clone()), "description") {
manifest.insert(
"description".to_string(),
Value::String(description.to_string()),
);
}
for (key, value) in live_manifest {
if matches!(
key.as_str(),
"id" | "name" | "description" | "logo" | "background"
) {
continue;
}
if matches!(
key.as_str(),
"resources" | "types" | "catalogs" | "idPrefixes"
) {
if let Some(value) = non_empty_array(Some(value)) {
manifest.insert(key.to_string(), value);
}
continue;
}
if value_has_content(value) {
manifest.insert(key.to_string(), value.clone());
}
}
let current_logo = current_manifest
.get("logo")
.and_then(Value::as_str)
.map(str::to_string);
let current_background = current_manifest
.get("background")
.and_then(Value::as_str)
.map(str::to_string);
let live_logo = live_manifest
.get("logo")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(str::to_string);
let live_background = live_manifest
.get("background")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(str::to_string);
let resolved_current_logo = resolve_asset_url(current_logo, &normalized_transport_url);
let resolved_current_background =
resolve_asset_url(current_background, &normalized_transport_url);
let logo = live_logo
.or(resolved_current_logo)
.or_else(|| live_background.clone())
.or_else(|| resolved_current_background.clone());
let background = live_background.or(resolved_current_background);
set_or_null(manifest, "logo", logo);
set_or_null(manifest, "background", background);
serde_json::to_string(&descriptor).ok()
}

View file

@ -0,0 +1,281 @@
use super::assets::string_array;
use serde_json::{json, Map, Value};
use std::collections::HashSet;
pub(crate) fn parse_catalogs(json: &Value) -> Vec<Value> {
json.get("catalogs")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let object = item.as_object()?;
let mut map = object.clone();
let extra = object
.get("extra")
.and_then(Value::as_array)
.map(|extras| {
extras
.iter()
.filter_map(|extra| {
let extra_object = extra.as_object()?;
let mut map = Map::new();
if let Some(name) = extra_object
.get("name")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
{
map.insert(
"name".to_string(),
Value::String(name.to_string()),
);
}
let options = string_array(extra, "options");
if !options.is_empty() {
map.insert("options".to_string(), Value::Array(options));
}
if let Some(is_required) =
extra_object.get("isRequired").and_then(Value::as_bool)
{
map.insert(
"isRequired".to_string(),
Value::Bool(is_required),
);
}
if let Some(options_limit) =
extra_object.get("optionsLimit").and_then(Value::as_i64)
{
map.insert(
"optionsLimit".to_string(),
json!(options_limit as i32),
);
}
if let Some(default_value) =
extra_object.get("default").and_then(Value::as_str)
{
map.insert(
"default".to_string(),
Value::String(default_value.to_string()),
);
}
Some(Value::Object(map))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !extra.is_empty() {
map.insert("extra".to_string(), Value::Array(extra));
}
let extras = map
.get("extra")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let extra_supported =
string_array(&Value::Object(map.clone()), "extraSupported");
let supports_initial_load = !extras.iter().any(|extra| {
extra
.get("isRequired")
.and_then(Value::as_bool)
.unwrap_or(false)
});
let supports_search = extra_supported.iter().any(|extra| {
extra
.as_str()
.is_some_and(|name| name.eq_ignore_ascii_case("search"))
}) || extras.iter().any(|extra| {
extra
.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name.eq_ignore_ascii_case("search"))
});
let has_required_extra_except_genre = extras.iter().any(|extra| {
extra
.get("isRequired")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !extra
.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name.eq_ignore_ascii_case("genre"))
});
map.insert(
"supportsInitialLoad".to_string(),
Value::Bool(supports_initial_load),
);
map.insert("supportsSearch".to_string(), Value::Bool(supports_search));
map.insert(
"hasRequiredExtraExceptGenre".to_string(),
Value::Bool(has_required_extra_except_genre),
);
Some(Value::Object(map))
})
.collect()
})
.unwrap_or_default()
}
pub(crate) fn canonical_resource_name(value: &str) -> String {
match value.to_ascii_lowercase().trim_end_matches('s') {
"metadata" => "meta".to_string(),
"subtitle" => "subtitle".to_string(),
other => other.to_string(),
}
}
pub(crate) fn to_string_vec(value: Option<&Value>) -> Vec<String> {
match value {
Some(Value::Array(items)) => items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.filter(|item| !item.is_empty())
.collect(),
Some(Value::String(text)) if !text.is_empty() => vec![text.to_string()],
_ => Vec::new(),
}
}
pub(crate) fn supports_resource(
manifest_json: &str,
resource_name: &str,
content_type: Option<&str>,
id: Option<&str>,
) -> bool {
let Ok(manifest) = serde_json::from_str::<Value>(manifest_json) else {
return false;
};
let expected = canonical_resource_name(resource_name);
let manifest_types = to_string_vec(manifest.get("types"));
let manifest_prefixes = to_string_vec(manifest.get("idPrefixes"));
manifest
.get("resources")
.and_then(Value::as_array)
.map(|resources| {
resources.iter().any(|resource| {
let (name, types, prefixes) = match resource {
Value::String(name) => (
name.as_str(),
manifest_types.clone(),
manifest_prefixes.clone(),
),
Value::Object(map) => {
let Some(name) = map.get("name").and_then(Value::as_str) else {
return false;
};
let types = to_string_vec(map.get("types"))
.into_iter()
.chain(to_string_vec(map.get("type")))
.collect::<Vec<_>>();
let prefixes = to_string_vec(map.get("idPrefixes"))
.into_iter()
.chain(to_string_vec(map.get("idPrefix")))
.collect::<Vec<_>>();
(
name,
if types.is_empty() {
manifest_types.clone()
} else {
types
},
if prefixes.is_empty() {
manifest_prefixes.clone()
} else {
prefixes
},
)
}
_ => return false,
};
if canonical_resource_name(name) != expected {
return false;
}
if let Some(content_type) = content_type {
if !types.is_empty()
&& !types
.iter()
.any(|item| item.eq_ignore_ascii_case(content_type))
{
return false;
}
}
if let Some(id) = id {
if canonical_resource_name(name) != "catalog"
&& !prefixes.is_empty()
&& !prefixes.iter().any(|prefix| id.starts_with(prefix))
{
return false;
}
}
true
})
})
.unwrap_or(false)
}
pub(crate) fn catalog_supports_extra(catalog_json: &str, extra_name: &str) -> bool {
let Ok(catalog) = serde_json::from_str::<Value>(catalog_json) else {
return false;
};
catalog
.get("extraSupported")
.and_then(Value::as_array)
.is_some_and(|items| {
items
.iter()
.filter_map(Value::as_str)
.any(|item| item.eq_ignore_ascii_case(extra_name))
})
|| catalog
.get("extra")
.and_then(Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name.eq_ignore_ascii_case(extra_name))
})
})
}
pub(crate) fn catalog_requires_extra(catalog_json: &str, extra_name: &str) -> bool {
let Ok(catalog) = serde_json::from_str::<Value>(catalog_json) else {
return false;
};
catalog
.get("extra")
.and_then(Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name.eq_ignore_ascii_case(extra_name))
&& item.get("isRequired").and_then(Value::as_bool) == Some(true)
})
})
}
pub(crate) fn catalog_has_required_extra_except(
catalog_json: &str,
allowed_names_json: &str,
) -> bool {
let Ok(catalog) = serde_json::from_str::<Value>(catalog_json) else {
return false;
};
let allowed_names = serde_json::from_str::<Vec<String>>(allowed_names_json)
.unwrap_or_default()
.into_iter()
.collect::<HashSet<_>>();
catalog
.get("extra")
.and_then(Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("isRequired").and_then(Value::as_bool) == Some(true)
&& item
.get("name")
.and_then(Value::as_str)
.map(|name| !allowed_names.contains(&name.to_ascii_lowercase()))
.unwrap_or(true)
})
})
}

View file

@ -0,0 +1,141 @@
use super::assets::{first_text, resolve_asset_url, string_array};
use super::catalogs::{canonical_resource_name, parse_catalogs};
use serde_json::{json, Value};
// pub rather than pub(crate): re-exported under fuzz_targets for the `fuzz/`
// crate (see lib.rs). Not part of the supported public API otherwise.
pub fn parse_manifest(body: &str, transport_url: &str, unknown_name: &str) -> Option<String> {
let json: Value = serde_json::from_str(body).ok()?;
let behavior_hints = json.get("behaviorHints");
let logo = first_text(
&json,
behavior_hints,
&["logo", "icon", "iconUrl", "poster", "posterUrl"],
);
let background = first_text(
&json,
behavior_hints,
&["background", "backgroundUrl", "backdrop", "backdropUrl"],
);
let description = first_text(
&json,
behavior_hints,
&[
"description",
"shortDescription",
"longDescription",
"summary",
],
);
let resources = json
.get("resources")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let id_prefixes = string_array(&json, "idPrefixes");
let mut manifest = json.as_object().cloned().unwrap_or_default();
manifest.insert(
"id".to_string(),
Value::String(
json.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
),
);
manifest.insert(
"name".to_string(),
Value::String(
json.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.unwrap_or(unknown_name)
.to_string(),
),
);
manifest.insert(
"description".to_string(),
description.map(Value::String).unwrap_or(Value::Null),
);
manifest.insert(
"version".to_string(),
first_text(&json, behavior_hints, &["version"])
.map(Value::String)
.unwrap_or(Value::Null),
);
manifest.insert("resources".to_string(), Value::Array(resources));
manifest.insert(
"supportsCatalog".to_string(),
Value::Bool(
json.get("resources")
.and_then(Value::as_array)
.is_some_and(|resources| {
resources.iter().any(|resource| match resource {
Value::String(name) => canonical_resource_name(name) == "catalog",
Value::Object(resource) => resource
.get("name")
.and_then(Value::as_str)
.is_some_and(|name| canonical_resource_name(name) == "catalog"),
_ => false,
})
}),
),
);
manifest.insert(
"types".to_string(),
Value::Array(string_array(&json, "types")),
);
manifest.insert("catalogs".to_string(), Value::Array(parse_catalogs(&json)));
manifest.insert(
"idPrefixes".to_string(),
if id_prefixes.is_empty() {
Value::Null
} else {
Value::Array(id_prefixes)
},
);
manifest.insert(
"logo".to_string(),
resolve_asset_url(logo, transport_url)
.map(Value::String)
.unwrap_or(Value::Null),
);
manifest.insert(
"background".to_string(),
resolve_asset_url(background, transport_url)
.map(Value::String)
.unwrap_or(Value::Null),
);
manifest.insert(
"configurable".to_string(),
behavior_hints
.and_then(|hints| hints.get("configurable"))
.and_then(Value::as_bool)
.map(Value::Bool)
.unwrap_or(Value::Null),
);
let descriptor = json!({
"manifest": Value::Object(manifest),
"transportUrl": transport_url
});
serde_json::to_string(&descriptor).ok()
}
pub(crate) fn normalize_addon_descriptor_json(addon_json: &str) -> Option<String> {
let addon: Value = serde_json::from_str(addon_json).ok()?;
if addon.get("manifest").is_some_and(Value::is_object) {
let mut normalized = addon.as_object()?.clone();
normalized
.entry("transportUrl")
.or_insert_with(|| Value::String(String::new()));
return serde_json::to_string(&normalized).ok();
}
let transport_url = addon
.get("transportUrl")
.or_else(|| addon.get("id"))
.and_then(Value::as_str)
.unwrap_or("");
parse_manifest(addon_json, transport_url, "Unknown Addon")
}

View file

@ -0,0 +1,30 @@
use serde_json::{json, Value};
fn meta_link_kind(category: &str) -> Option<&'static str> {
let category = category.to_lowercase();
if ["cast", "actor", "actors", "starring"]
.iter()
.any(|key| category.contains(key))
{
Some("cast")
} else if category.contains("director") {
Some("director")
} else {
None
}
}
pub(crate) fn classify_meta_links_json(links_json: &str) -> Option<String> {
let links: Vec<Value> = serde_json::from_str(links_json).ok()?;
let mut cast: Vec<Value> = Vec::new();
let mut directors: Vec<Value> = Vec::new();
for link in links {
let category = link.get("category").and_then(Value::as_str).unwrap_or("");
match meta_link_kind(category) {
Some("cast") => cast.push(link),
Some("director") => directors.push(link),
_ => {}
}
}
serde_json::to_string(&json!({ "cast": cast, "directors": directors })).ok()
}

208
src/addon_protocol/url.rs Normal file
View file

@ -0,0 +1,208 @@
use serde_json::{json, Map, Value};
pub(crate) fn is_http_url(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
pub(crate) fn strip_http_scheme(value: &str) -> &str {
value
.strip_prefix("http://")
.or_else(|| value.strip_prefix("https://"))
.or_else(|| value.strip_prefix("HTTP://"))
.or_else(|| value.strip_prefix("HTTPS://"))
.unwrap_or(value)
}
pub(crate) fn is_ipv4_like_host(value: &str) -> bool {
let host = value
.split('/')
.next()
.unwrap_or(value)
.split(':')
.next()
.unwrap_or(value);
let parts: Vec<&str> = host.split('.').collect();
parts.len() == 4 && parts.iter().all(|part| part.parse::<u8>().is_ok())
}
pub(crate) fn is_local_url(value: &str) -> bool {
let lower = strip_http_scheme(value).to_ascii_lowercase();
if lower.starts_with("localhost")
|| lower.starts_with("127.")
|| lower.starts_with("10.")
|| lower.starts_with("192.168.")
{
return true;
}
// 172.16.0.0/12 private range
if let Some(rest) = lower.strip_prefix("172.") {
if let Some(second_octet) = rest.split('.').next().and_then(|s| s.parse::<u8>().ok()) {
if (16..=31).contains(&second_octet) {
return true;
}
}
}
false
}
pub(crate) fn normalize_manifest_url(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return String::new();
}
let lower = trimmed.to_ascii_lowercase();
let with_scheme = if lower.starts_with("stremio://") {
format!("https://{}", &trimmed[10..])
} else if lower.starts_with("http://") {
if is_local_url(trimmed) {
trimmed.to_string()
} else {
format!("https://{}", &trimmed[7..])
}
} else if lower.starts_with("https://") {
trimmed.to_string()
} else if lower.starts_with("127.0.0.1")
|| lower.starts_with("10.0.2.2")
|| lower.starts_with("localhost")
|| is_ipv4_like_host(trimmed)
{
format!("http://{trimmed}")
} else {
format!("https://{trimmed}")
};
if with_scheme.to_ascii_lowercase().ends_with("manifest.json") {
with_scheme
} else if with_scheme.ends_with('/') {
format!("{with_scheme}manifest.json")
} else {
format!("{with_scheme}/manifest.json")
}
}
pub(crate) fn identity(raw: &str) -> String {
normalize_manifest_url(raw)
.trim_end_matches('/')
.trim_start_matches("https://")
.trim_start_matches("http://")
.to_string()
}
pub(crate) fn manifest_candidates(raw: &str) -> Vec<String> {
let normalized = normalize_manifest_url(raw);
let mut values = vec![normalized.clone()];
if is_local_url(&normalized) && normalized.to_ascii_lowercase().starts_with("https://") {
let fallback = format!("http://{}", &normalized[8..]);
if !values.contains(&fallback) {
values.push(fallback);
}
}
values
}
pub(crate) fn manifest_fetch_plan_json(raw: &str) -> Option<String> {
let normalized_transport_url = normalize_manifest_url(raw);
if normalized_transport_url.is_empty() {
return None;
}
serde_json::to_string(&json!({
"normalizedTransportUrl": normalized_transport_url,
"cacheKey": format!("manifest_v10_{}", normalized_transport_url),
"candidateUrls": manifest_candidates(&normalized_transport_url)
}))
.ok()
}
pub(crate) fn base_url(raw: &str) -> String {
let normalized = normalize_manifest_url(raw);
let without_manifest = match normalized.to_ascii_lowercase().rfind("manifest.json") {
Some(index) => normalized[..index].to_string(),
None => normalized,
};
let mut base = if without_manifest.ends_with('/') {
without_manifest
} else {
format!("{without_manifest}/")
};
let lower = base.to_ascii_lowercase();
if (lower.contains("localhost") || lower.contains("127.0.0.1")) && lower.starts_with("https://")
{
base = format!("http://{}", &base[8..]);
}
base
}
pub(crate) fn prefer_https_asset_url(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let lower = trimmed.to_ascii_lowercase();
if lower.starts_with("http://") {
if is_local_url(trimmed) {
Some(trimmed.to_string())
} else {
Some(format!("https://{}", &trimmed[7..]))
}
} else if trimmed.starts_with("//") {
Some(format!("https:{trimmed}"))
} else {
Some(trimmed.to_string())
}
}
pub(crate) fn encode_path_segment(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*');
if keep {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
pub(crate) fn build_resource_url(
raw: &str,
resource: &str,
content_type: &str,
id: &str,
extra_json: Option<&str>,
) -> String {
let extra_path = extra_json
.and_then(|value| serde_json::from_str::<Map<String, Value>>(value).ok())
.map(|map| {
map.into_iter()
.filter_map(|(key, value)| {
let text = value
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| value.to_string());
if text.trim().is_empty() {
None
} else {
Some(format!(
"{}={}",
encode_path_segment(&key),
encode_path_segment(&text)
))
}
})
.collect::<Vec<_>>()
.join("&")
})
.filter(|value| !value.is_empty())
.map(|value| format!("/{value}"))
.unwrap_or_default();
format!(
"{}{}/{}/{}{}.json",
base_url(raw),
resource,
encode_path_segment(content_type),
encode_path_segment(id),
extra_path
)
}