mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-06 16:59:38 +00:00
parse_catalogs rebuilds each catalog's extra list from scratch and only whitelisted name/options/isRequired/optionsLimit, silently dropping "default" -- the field a required extra (e.g. TMDB By Year's "genre", really a year picker) needs so the client can pre-select it. This is upstream of both catalog_extras() and CatalogExtra's Kotlin model; those were already correct but had nothing to read since the field never survived this far. Confirmed against the live addon manifest that reports "default":"2026" for that catalog.
1186 lines
42 KiB
Rust
1186 lines
42 KiB
Rust
use serde_json::{json, Map, Value};
|
|
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()
|
|
}
|
|
|
|
#[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::{json, Value};
|
|
|
|
#[test]
|
|
fn resolve_manifest_assets_normalizes_transport_and_relative_assets() {
|
|
let descriptor = r#"{
|
|
"transportUrl":"addon.example/root/manifest.json",
|
|
"manifest":{
|
|
"id":"addon",
|
|
"name":"Addon",
|
|
"description":"",
|
|
"resources":[],
|
|
"types":[],
|
|
"catalogs":[],
|
|
"logo":"logo.png",
|
|
"background":"/bg.jpg"
|
|
}
|
|
}"#;
|
|
let resolved = resolve_manifest_assets_json(descriptor)
|
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
|
.expect("resolved descriptor");
|
|
|
|
assert_eq!(
|
|
resolved.get("transportUrl").and_then(Value::as_str),
|
|
Some("https://addon.example/root/manifest.json")
|
|
);
|
|
assert_eq!(
|
|
resolved
|
|
.get("manifest")
|
|
.and_then(|manifest| manifest.get("description")),
|
|
Some(&Value::Null)
|
|
);
|
|
assert_eq!(
|
|
resolved
|
|
.get("manifest")
|
|
.and_then(|manifest| manifest.get("logo"))
|
|
.and_then(Value::as_str),
|
|
Some("https://addon.example/root/logo.png")
|
|
);
|
|
assert_eq!(
|
|
resolved
|
|
.get("manifest")
|
|
.and_then(|manifest| manifest.get("background"))
|
|
.and_then(Value::as_str),
|
|
Some("https://addon.example/bg.jpg")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_fetch_plan_owns_cache_key_and_candidates() {
|
|
let plan = super::manifest_fetch_plan_json("127.0.0.1:7000")
|
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
|
.expect("manifest fetch plan");
|
|
|
|
assert_eq!(
|
|
plan.get("normalizedTransportUrl").and_then(Value::as_str),
|
|
Some("http://127.0.0.1:7000/manifest.json")
|
|
);
|
|
assert_eq!(
|
|
plan.get("cacheKey").and_then(Value::as_str),
|
|
Some("manifest_v10_http://127.0.0.1:7000/manifest.json")
|
|
);
|
|
assert_eq!(
|
|
plan.get("candidateUrls")
|
|
.and_then(Value::as_array)
|
|
.and_then(|items| items.first())
|
|
.and_then(Value::as_str),
|
|
Some("http://127.0.0.1:7000/manifest.json")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_live_manifest_keeps_current_fields_when_live_is_empty() {
|
|
let current = r#"{
|
|
"transportUrl":"https://addon.example/manifest.json",
|
|
"manifest":{
|
|
"id":"old",
|
|
"name":"Old",
|
|
"description":"Current",
|
|
"version":"1.0",
|
|
"resources":["stream"],
|
|
"types":["movie"],
|
|
"catalogs":[{"type":"movie","id":"old"}],
|
|
"logo":"logo.png",
|
|
"background":"bg.jpg",
|
|
"configurable":false
|
|
}
|
|
}"#;
|
|
let live = r#"{
|
|
"transportUrl":"https://addon.example/manifest.json",
|
|
"manifest":{
|
|
"id":"new",
|
|
"name":"Unknown",
|
|
"description":"",
|
|
"version":"2.0",
|
|
"resources":[],
|
|
"types":[],
|
|
"catalogs":[],
|
|
"logo":null,
|
|
"background":"live-bg.jpg",
|
|
"configurable":true
|
|
}
|
|
}"#;
|
|
let merged = merge_live_manifest_json(current, Some(live), "Unknown")
|
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
|
.expect("merged descriptor");
|
|
let manifest = merged.get("manifest").expect("manifest");
|
|
|
|
assert_eq!(manifest.get("id").and_then(Value::as_str), Some("new"));
|
|
assert_eq!(manifest.get("name").and_then(Value::as_str), Some("Old"));
|
|
assert_eq!(
|
|
manifest.get("description").and_then(Value::as_str),
|
|
Some("Current")
|
|
);
|
|
assert_eq!(manifest.get("version").and_then(Value::as_str), Some("2.0"));
|
|
assert_eq!(
|
|
manifest
|
|
.get("resources")
|
|
.and_then(Value::as_array)
|
|
.map(Vec::len),
|
|
Some(1)
|
|
);
|
|
assert_eq!(
|
|
manifest.get("logo").and_then(Value::as_str),
|
|
Some("https://addon.example/logo.png")
|
|
);
|
|
assert_eq!(
|
|
manifest.get("background").and_then(Value::as_str),
|
|
Some("live-bg.jpg")
|
|
);
|
|
assert_eq!(
|
|
manifest.get("configurable").and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_manifest_preserves_stremio_manifest_fields() {
|
|
let parsed = parse_manifest(
|
|
r#"{
|
|
"id":"org.example.full",
|
|
"version":"1.2.3",
|
|
"name":"Full",
|
|
"description":"All fields",
|
|
"resources":["catalog",{"name":"stream","types":["movie"],"idPrefixes":["tt"]}],
|
|
"types":["movie","series"],
|
|
"idPrefixes":["tt"],
|
|
"catalogs":[{"type":"movie","id":"top","name":"Top","extra":[{"name":"genre","isRequired":false,"options":["Drama"],"optionsLimit":2,"default":"Drama"}],"extraSupported":["search"],"extraRequired":["genre"]}],
|
|
"addonCatalogs":[{"type":"addon","id":"community","name":"Community"}],
|
|
"config":[{"key":"token","type":"password","default":"x","title":"Token","required":true}],
|
|
"background":"/bg.jpg",
|
|
"logo":"logo.png",
|
|
"contactEmail":"ops@example.com",
|
|
"behaviorHints":{"adult":true,"p2p":true,"configurable":true,"configurationRequired":true}
|
|
}"#,
|
|
"https://addon.example/root/manifest.json",
|
|
"Unknown",
|
|
)
|
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
|
.expect("parsed manifest");
|
|
let manifest = parsed.get("manifest").expect("manifest");
|
|
|
|
assert_eq!(
|
|
manifest["addonCatalogs"][0]["id"].as_str(),
|
|
Some("community")
|
|
);
|
|
assert_eq!(manifest["config"][0]["key"].as_str(), Some("token"));
|
|
assert_eq!(manifest["contactEmail"].as_str(), Some("ops@example.com"));
|
|
assert_eq!(
|
|
manifest["behaviorHints"]["configurationRequired"].as_bool(),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
manifest["catalogs"][0]["extraRequired"][0].as_str(),
|
|
Some("genre")
|
|
);
|
|
assert_eq!(
|
|
manifest["catalogs"][0]["extra"][0]["default"].as_str(),
|
|
Some("Drama")
|
|
);
|
|
assert_eq!(
|
|
manifest["logo"].as_str(),
|
|
Some("https://addon.example/root/logo.png")
|
|
);
|
|
assert_eq!(
|
|
manifest["background"].as_str(),
|
|
Some("https://addon.example/bg.jpg")
|
|
);
|
|
assert_eq!(manifest["supportsCatalog"].as_bool(), Some(true));
|
|
assert_eq!(
|
|
manifest["catalogs"][0]["supportsInitialLoad"].as_bool(),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
manifest["catalogs"][0]["supportsSearch"].as_bool(),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
manifest["catalogs"][0]["hasRequiredExtraExceptGenre"].as_bool(),
|
|
Some(false)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_live_manifest_preserves_new_live_manifest_fields() {
|
|
let current = r#"{
|
|
"transportUrl":"https://addon.example/manifest.json",
|
|
"manifest":{"id":"old","name":"Old","description":"Current","resources":["stream"],"types":["movie"],"catalogs":[]}
|
|
}"#;
|
|
let live = r#"{
|
|
"manifest":{
|
|
"id":"old",
|
|
"name":"Live",
|
|
"description":"Live description",
|
|
"resources":["stream"],
|
|
"types":["movie"],
|
|
"catalogs":[],
|
|
"addonCatalogs":[{"type":"addon","id":"community","name":"Community"}],
|
|
"config":[{"key":"token","type":"password"}],
|
|
"contactEmail":"ops@example.com",
|
|
"behaviorHints":{"configurable":true,"configurationRequired":true}
|
|
}
|
|
}"#;
|
|
let merged = merge_live_manifest_json(current, Some(live), "Unknown")
|
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
|
.expect("merged manifest");
|
|
let manifest = merged.get("manifest").expect("manifest");
|
|
|
|
assert_eq!(
|
|
manifest["addonCatalogs"][0]["id"].as_str(),
|
|
Some("community")
|
|
);
|
|
assert_eq!(manifest["config"][0]["key"].as_str(), Some("token"));
|
|
assert_eq!(manifest["contactEmail"].as_str(), Some("ops@example.com"));
|
|
assert_eq!(
|
|
manifest["behaviorHints"]["configurationRequired"].as_bool(),
|
|
Some(true)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_resource_url_appends_extra_path_segment_and_omits_blank_values() {
|
|
assert_eq!(
|
|
build_resource_url(
|
|
"https://addon.example/manifest.json",
|
|
"stream",
|
|
"movie",
|
|
"tt123",
|
|
None
|
|
),
|
|
"https://addon.example/stream/movie/tt123.json"
|
|
);
|
|
assert_eq!(
|
|
build_resource_url(
|
|
"https://addon.example/manifest.json",
|
|
"catalog",
|
|
"movie",
|
|
"top",
|
|
Some(r#"{"genre":"action"}"#),
|
|
),
|
|
"https://addon.example/catalog/movie/top/genre=action.json"
|
|
);
|
|
// A blank extra value contributes nothing — same shape as no extra at all.
|
|
assert_eq!(
|
|
build_resource_url(
|
|
"https://addon.example/manifest.json",
|
|
"stream",
|
|
"movie",
|
|
"tt123",
|
|
Some(r#"{"genre":""}"#),
|
|
),
|
|
"https://addon.example/stream/movie/tt123.json"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn supports_resource_gates_on_content_type_but_catalog_bypasses_id_prefix() {
|
|
let manifest = json!({
|
|
"resources": ["stream"],
|
|
"types": ["movie"],
|
|
"idPrefixes": ["tt"],
|
|
});
|
|
assert!(supports_resource(
|
|
&manifest.to_string(),
|
|
"streams",
|
|
Some("movie"),
|
|
Some("tt123")
|
|
));
|
|
// Wrong content type for this manifest's declared types.
|
|
assert!(!supports_resource(
|
|
&manifest.to_string(),
|
|
"stream",
|
|
Some("series"),
|
|
Some("tt123")
|
|
));
|
|
|
|
let catalog_manifest = json!({
|
|
"resources": [{ "name": "catalog", "types": ["movie"], "idPrefixes": ["tt"] }],
|
|
});
|
|
// catalog resources never gate on id prefix, even though one is declared.
|
|
assert!(supports_resource(
|
|
&catalog_manifest.to_string(),
|
|
"catalog",
|
|
Some("movie"),
|
|
Some("does-not-match-prefix")
|
|
));
|
|
|
|
assert!(!supports_resource("not json", "stream", None, None));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_descriptor_normalization_reuses_manifest_parser() {
|
|
let normalized: Value = serde_json::from_str(
|
|
&normalize_addon_descriptor_json(
|
|
r#"{"id":"legacy","name":"Legacy","resources":["stream"],"customField":"kept"}"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(normalized["transportUrl"], "legacy");
|
|
assert_eq!(normalized["manifest"]["customField"], "kept");
|
|
}
|
|
}
|