//! The installed extension manifest contract, independent of JavaScript execution. use crate::storage::valid_extension_id; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Map, Value}; use spotiflac_network::url::UrlParts; trait GoNull { fn set_null(&mut self); } impl GoNull for String { fn set_null(&mut self) {} } impl GoNull for bool { fn set_null(&mut self) {} } impl GoNull for isize { fn set_null(&mut self) {} } impl GoNull for Vec { fn set_null(&mut self) { self.clear(); } } impl GoNull for Option { fn set_null(&mut self) { *self = None; } } impl GoNull for Map { fn set_null(&mut self) { self.clear(); } } impl GoNull for Value { fn set_null(&mut self) { *self = Value::Null; } } fn is_false(value: &bool) -> bool { !value } fn is_zero(value: &isize) -> bool { *value == 0 } // Go treats missing and null scalar fields as their zero values. Keeping wire // names explicit also prevents Rust naming changes from changing mobile JSON. macro_rules! manifest_struct { ($name:ident { $($(#[$attr:meta])* $field:ident: $ty:ty => $wire:literal),* $(,)? }) => { #[derive(Clone, Debug, Default, Serialize)] pub struct $name { $( $(#[$attr])* #[serde(rename = $wire)] pub $field: $ty, )* } impl GoNull for $name { fn set_null(&mut self) {} } impl<'de> Deserialize<'de> for $name { fn deserialize>(deserializer: D) -> Result { struct Visitor; impl<'de> serde::de::Visitor<'de> for Visitor { type Value = $name; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a manifest object") } fn visit_unit(self) -> Result { Ok($name::default()) } fn visit_map>(self, mut map: A) -> Result { let mut result = $name::default(); while let Some(key) = map.next_key::()? { $( if key.eq_ignore_ascii_case($wire) { match map.next_value::>()? { Some(value) => result.$field = value, None => result.$field.set_null(), } continue; } )* map.next_value::()?; } Ok(result) } } deserializer.deserialize_any(Visitor) } } }; } manifest_struct!(ExtensionPermissions { network: Option> => "network", storage: bool => "storage", file: bool => "file", #[serde(skip_serializing_if = "is_false")] allow_http: bool => "allowHttp", }); manifest_struct!(ExtensionSetting { key: String => "key", kind: String => "type", label: String => "label", #[serde(skip_serializing_if = "String::is_empty")] description: String => "description", #[serde(skip_serializing_if = "is_false")] required: bool => "required", #[serde(skip_serializing_if = "is_false")] secret: bool => "secret", #[serde(skip_serializing_if = "Value::is_null")] default: Value => "default", #[serde(skip_serializing_if = "Vec::is_empty")] options: Vec => "options", #[serde(skip_serializing_if = "String::is_empty")] action: String => "action", }); manifest_struct!(QualitySpecificSetting { key: String => "key", kind: String => "type", label: String => "label", #[serde(skip_serializing_if = "String::is_empty")] description: String => "description", #[serde(skip_serializing_if = "is_false")] required: bool => "required", #[serde(skip_serializing_if = "is_false")] secret: bool => "secret", #[serde(skip_serializing_if = "Value::is_null")] default: Value => "default", #[serde(skip_serializing_if = "Vec::is_empty")] options: Vec => "options", }); manifest_struct!(QualityOption { id: String => "id", #[serde(skip_serializing_if = "String::is_empty")] kind: String => "kind", label: String => "label", description: String => "description", #[serde(skip_serializing_if = "Vec::is_empty")] settings: Vec => "settings", }); manifest_struct!(SearchFilter { id: String => "id", #[serde(skip_serializing_if = "String::is_empty")] label: String => "label", #[serde(skip_serializing_if = "String::is_empty")] icon: String => "icon", }); manifest_struct!(SearchBehavior { enabled: bool => "enabled", #[serde(skip_serializing_if = "String::is_empty")] placeholder: String => "placeholder", #[serde(skip_serializing_if = "is_false")] primary: bool => "primary", #[serde(skip_serializing_if = "String::is_empty")] icon: String => "icon", #[serde(skip_serializing_if = "String::is_empty")] thumbnail_ratio: String => "thumbnailRatio", #[serde(skip_serializing_if = "is_zero")] thumbnail_width: isize => "thumbnailWidth", #[serde(skip_serializing_if = "is_zero")] thumbnail_height: isize => "thumbnailHeight", #[serde(skip_serializing_if = "Vec::is_empty")] filters: Vec => "filters", }); manifest_struct!(UrlHandler { enabled: bool => "enabled", #[serde(skip_serializing_if = "Vec::is_empty")] patterns: Vec => "patterns", }); manifest_struct!(TrackMatching { custom_matching: bool => "customMatching", #[serde(skip_serializing_if = "String::is_empty")] strategy: String => "strategy", #[serde(skip_serializing_if = "is_zero")] duration_tolerance: isize => "durationTolerance", }); manifest_struct!(PostProcessingHook { id: String => "id", name: String => "name", #[serde(skip_serializing_if = "String::is_empty")] description: String => "description", #[serde(skip_serializing_if = "is_false")] default_enabled: bool => "defaultEnabled", #[serde(skip_serializing_if = "Vec::is_empty")] supported_formats: Vec => "supportedFormats", }); manifest_struct!(PostProcessing { enabled: bool => "enabled", #[serde(skip_serializing_if = "Vec::is_empty")] hooks: Vec => "hooks", }); manifest_struct!(HealthCheck { id: String => "id", #[serde(skip_serializing_if = "String::is_empty")] label: String => "label", url: String => "url", #[serde(skip_serializing_if = "String::is_empty")] method: String => "method", #[serde(skip_serializing_if = "String::is_empty")] service_key: String => "serviceKey", #[serde(skip_serializing_if = "is_zero")] timeout_ms: isize => "timeoutMs", #[serde(skip_serializing_if = "is_zero")] cache_ttl_seconds: isize => "cacheTtlSeconds", #[serde(skip_serializing_if = "is_false")] required: bool => "required", }); manifest_struct!(SignedSessionEndpoints { #[serde(skip_serializing_if = "String::is_empty")] bootstrap: String => "bootstrap", #[serde(skip_serializing_if = "String::is_empty")] challenge: String => "challenge", #[serde(skip_serializing_if = "String::is_empty")] exchange: String => "exchange", #[serde(skip_serializing_if = "String::is_empty")] refresh: String => "refresh", }); manifest_struct!(SignedSession { namespace: String => "namespace", base_url: String => "baseUrl", #[serde(skip_serializing_if = "String::is_empty")] app_version: String => "appVersion", #[serde(skip_serializing_if = "String::is_empty")] platform: String => "platform", #[serde(skip_serializing_if = "String::is_empty")] callback_url: String => "callbackUrl", #[serde(skip_serializing_if = "String::is_empty")] scheme_label: String => "schemeLabel", #[serde(skip_serializing_if = "String::is_empty")] header_prefix: String => "headerPrefix", #[serde(skip_serializing_if = "is_zero")] time_window_seconds: isize => "timeWindowSeconds", endpoints: SignedSessionEndpoints => "endpoints", }); manifest_struct!(ExtensionManifest { name: String => "name", display_name: String => "displayName", version: String => "version", description: String => "description", #[serde(skip_serializing_if = "String::is_empty")] homepage: String => "homepage", #[serde(skip_serializing_if = "String::is_empty")] icon: String => "icon", types: Vec => "type", permissions: ExtensionPermissions => "permissions", #[serde(skip_serializing_if = "Vec::is_empty")] settings: Vec => "settings", #[serde(skip_serializing_if = "Vec::is_empty")] quality_options: Vec => "qualityOptions", #[serde(skip_serializing_if = "String::is_empty")] min_app_version: String => "minAppVersion", #[serde(skip_serializing_if = "is_false")] skip_metadata_enrichment: bool => "skipMetadataEnrichment", #[serde(skip_serializing_if = "is_false")] skip_lyrics: bool => "skipLyrics", #[serde(skip_serializing_if = "is_false")] stop_provider_fallback: bool => "stopProviderFallback", #[serde(skip_serializing_if = "is_false")] skip_built_in_fallback: bool => "skipBuiltInFallback", #[serde(skip_serializing_if = "Option::is_none")] search_behavior: Option => "searchBehavior", #[serde(skip_serializing_if = "Option::is_none")] url_handler: Option => "urlHandler", #[serde(skip_serializing_if = "Option::is_none")] track_matching: Option => "trackMatching", #[serde(skip_serializing_if = "Option::is_none")] post_processing: Option => "postProcessing", #[serde(skip_serializing_if = "Vec::is_empty")] service_health: Vec => "serviceHealth", #[serde(skip_serializing_if = "Option::is_none")] signed_session: Option => "signedSession", #[serde(skip_serializing_if = "Vec::is_empty")] required_runtime_features: Vec => "requiredRuntimeFeatures", #[serde(skip_serializing_if = "Map::is_empty")] capabilities: Map => "capabilities", }); #[derive(Debug, thiserror::Error)] pub enum ManifestError { #[error("failed to parse manifest JSON: {0}")] Json(#[from] serde_json::Error), #[error("manifest validation error: {field} - {message}")] Validation { field: String, message: String }, } fn invalid(field: impl Into, message: impl Into) -> ManifestError { ManifestError::Validation { field: field.into(), message: message.into(), } } fn extension_quality_kind(option: &QualityOption, manifest: Option<&ExtensionManifest>) -> String { let kind = option.kind.trim().to_ascii_lowercase(); if matches!(kind.as_str(), "lossless" | "lossy" | "spatial") { return kind; } // Installed manifests from before the kind field was introduced need the // same ID/label inference as the Go backend. Descriptions are deliberately // excluded because they commonly mention fallback formats. let token = option.id.trim().to_ascii_lowercase(); let text = format!("{token} {}", option.label.to_ascii_lowercase()); if text.contains("atmos") || text.contains("dolby") || text.contains("surround") || matches!(token.as_str(), "ac4" | "ac-4" | "eac3" | "e-ac-3" | "ec-3") { return "spatial".to_owned(); } if text.contains("lossless") || text.contains("flac") || text.contains("alac") || text.contains("24-bit") || text.contains("16-bit") || token == "hi_res" { return "lossless".to_owned(); } if token == "high" || token == "low" || text.contains("mp3") || text.contains("aac") || text.contains("opus") || text.contains("vorbis") { return "lossy".to_owned(); } if matches!(token.as_str(), "best" | "default" | "") && let Some(tier) = manifest .and_then(|value| value.capabilities.get("downloadFallbackTier")) .and_then(Value::as_str) { match tier.trim().to_ascii_lowercase().as_str() { "hi_res" | "lossless" => return "lossless".to_owned(), "low_res" => return "lossy".to_owned(), _ => {} } } String::new() } impl ExtensionManifest { pub fn parse(json: &str) -> Result { let manifest: Self = serde_json::from_str(json)?; manifest.validate()?; Ok(manifest) } pub fn validate(&self) -> Result<(), ManifestError> { if self.name.trim().is_empty() { return Err(invalid("name", "name is required")); } if !valid_extension_id(&self.name) { return Err(invalid( "name", "name must be a lowercase extension ID containing only letters, numbers, '.', '_' or '-'", )); } if self.version.trim().is_empty() { return Err(invalid("version", "version is required")); } if self.description.trim().is_empty() { return Err(invalid("description", "description is required")); } if self.types.is_empty() { return Err(invalid("type", "at least one type is required")); } for kind in &self.types { if !matches!( kind.as_str(), "metadata_provider" | "download_provider" | "lyrics_provider" ) { return Err(invalid( "type", format!( "invalid extension type: {kind} (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')" ), )); } } for (index, setting) in self.settings.iter().enumerate() { let failure = if setting.key.trim().is_empty() { Some(("key", "setting key is required")) } else if setting.kind.is_empty() { Some(("type", "setting type is required")) } else if setting.kind == "select" && setting.options.is_empty() { Some(("options", "select type requires options")) } else if setting.kind == "button" && setting.action.is_empty() { Some(("action", "button type requires action (JS function name)")) } else { None }; if let Some((field, message)) = failure { return Err(invalid(format!("settings[{index}].{field}"), message)); } } for (index, quality) in self.quality_options.iter().enumerate() { if !matches!(quality.kind.as_str(), "" | "lossless" | "lossy" | "spatial") { return Err(invalid( format!("qualityOptions[{index}].kind"), "quality kind must be lossless, lossy, or spatial", )); } } for (index, check) in self.service_health.iter().enumerate() { let failure = if check.id.trim().is_empty() { Some(("id", "health check id is required")) } else if check.url.trim().is_empty() { Some(("url", "health check url is required")) } else if !matches!( check.method.trim().to_uppercase().as_str(), "" | "GET" | "HEAD" ) { Some(("method", "health check method must be GET or HEAD")) } else { None }; if let Some((field, message)) = failure { return Err(invalid(format!("serviceHealth[{index}].{field}"), message)); } } if let Some(session) = &self.signed_session { if !self.permissions.storage { return Err(invalid( "permissions.storage", "signedSession requires storage permission", )); } if session.namespace.trim().is_empty() { return Err(invalid("signedSession.namespace", "namespace is required")); } let base = session.base_url.trim(); if base.is_empty() { return Err(invalid("signedSession.baseUrl", "baseUrl is required")); } if !base.to_lowercase().starts_with("https://") { return Err(invalid("signedSession.baseUrl", "baseUrl must use https")); } let parsed = UrlParts::parse(base) .filter(|url| !url.hostname.is_empty()) .ok_or_else(|| invalid("signedSession.baseUrl", "baseUrl is invalid"))?; if !self.is_domain_allowed(&parsed.hostname) { return Err(invalid( "signedSession.baseUrl", "baseUrl host must be listed in permissions.network", )); } } if self.has_capability("rawFfmpeg") && !self.permissions.file { return Err(invalid( "permissions.file", "rawFfmpeg capability requires file permission", )); } crate::transfer_policy::validate(&self.capabilities) .map_err(|message| invalid("capabilities.downloadTransfer", message))?; Ok(()) } pub fn has_capability(&self, name: &str) -> bool { self.capabilities.get(name) == Some(&Value::Bool(true)) } pub fn find_quality(&self, requested: &str) -> Option<&QualityOption> { let requested = requested.trim(); if requested.is_empty() { return None; } self.quality_options .iter() .find(|option| option.id.trim().eq_ignore_ascii_case(requested)) } pub fn resolve_download_quality( &self, requested: &str, source: Option<&ExtensionManifest>, ) -> Result { let requested = requested.trim(); if self.quality_options.is_empty() { return Ok(requested.to_owned()); } let source_quality = source.and_then(|manifest| manifest.find_quality(requested)); let source_option = source_quality.cloned().unwrap_or_else(|| QualityOption { id: requested.to_owned(), ..QualityOption::default() }); let kind = extension_quality_kind(&source_option, source); if let Some(exact) = self.find_quality(requested) { let target_kind = extension_quality_kind(exact, Some(self)); if source.is_some_and(|manifest| manifest.name == self.name) || (!kind.is_empty() && kind == target_kind) || (kind.is_empty() && target_kind != "spatial") { return Ok(exact.id.trim().to_owned()); } } let kind = if kind.is_empty() { "lossless" } else { kind.as_str() }; let allowed_kinds: Vec<&str> = if matches!(kind, "spatial" | "lossy") { vec![kind, "lossless"] } else { vec![kind] }; for allowed in allowed_kinds { if let Some(candidate) = self.quality_options.iter().find(|candidate| { let id = candidate.id.trim(); !id.is_empty() && extension_quality_kind(candidate, Some(self)) == *allowed }) { return Ok(candidate.id.trim().to_owned()); } } Err(format!( "provider {} has no compatible {} quality for {:?}", self.name, kind, requested )) } pub fn has_type(&self, kind: &str) -> bool { self.types.iter().any(|value| value == kind) } pub fn stops_provider_fallback(&self) -> bool { self.stop_provider_fallback || self.skip_built_in_fallback } pub fn is_domain_allowed(&self, domain: &str) -> bool { let domain = domain.trim().to_lowercase(); self.permissions .network .as_deref() .unwrap_or_default() .iter() .any(|allowed| { let allowed = allowed.trim().to_lowercase(); allowed == domain || (allowed.starts_with("*.") && domain.ends_with(&allowed[1..])) }) } pub fn matches_url(&self, url: &str) -> bool { let Some(handler) = &self.url_handler else { return false; }; if !handler.enabled { return false; } let url = url.trim().to_lowercase(); let parsed = UrlParts::parse(&url); handler.patterns.iter().any(|pattern| { let pattern = pattern.trim().to_lowercase(); if pattern.is_empty() { return false; } if !pattern.contains('/') && pattern.ends_with(':') { return url.starts_with(&pattern); } let Some(parsed) = parsed.as_ref().filter(|url| !url.hostname.is_empty()) else { return false; }; let pattern = pattern .split_once("://") .map_or(pattern.as_str(), |(_, rest)| rest); let (host, path) = pattern.split_once('/').unwrap_or((pattern, "")); !host.is_empty() && (parsed.hostname == host || parsed.hostname.ends_with(&format!(".{host}"))) && (path.is_empty() || parsed.path.starts_with(format!("/{path}").as_bytes())) }) } }