mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-19 13:35:53 +00:00
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:
parent
41e19ee76f
commit
8eafbb2120
6 changed files with 883 additions and 858 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
205
src/addon_protocol/assets.rs
Normal file
205
src/addon_protocol/assets.rs
Normal 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()
|
||||
}
|
||||
281
src/addon_protocol/catalogs.rs
Normal file
281
src/addon_protocol/catalogs.rs
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
141
src/addon_protocol/manifest_parse.rs
Normal file
141
src/addon_protocol/manifest_parse.rs
Normal 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")
|
||||
}
|
||||
30
src/addon_protocol/meta_links.rs
Normal file
30
src/addon_protocol/meta_links.rs
Normal 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
208
src/addon_protocol/url.rs
Normal 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
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue